From f5cd7fe869637c1a2ad6feba24f92db5cbb4e59f Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Tue, 22 Sep 2026 22:58:21 -0700 Subject: [PATCH 1/3] feat(opds): reversible credentials, live auth swap, validated reconfigure Credentials are stored reversibly (ADR 0005) so the pane can show the current password instead of forcing a rotation. The Argon2 verifier and the machinery that made it safe (semaphore, busy response, HMAC result cache, response padding) are removed; exponential backoff remains the online-guessing defence. Auth reads a hot-swappable username/password snapshot per request, so configuring or generating credentials no longer stops a running share. Clearing credentials still does. reconfigure applies a port or scope change to a running share. Every config check runs before the old listeners are stopped, so a rejected config leaves the share running. Usernames containing a colon are rejected, since HTTP Basic could never authenticate them. New commands: clb_query_opds_credential_secret, clb_cmd_reconfigure_opds. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 39 +- crates/citadel-opds/Cargo.toml | 4 +- crates/citadel-opds/src/auth.rs | 542 +++++------------- crates/citadel-opds/src/catalog.rs | 2 +- crates/citadel-opds/src/credential_store.rs | 72 ++- crates/citadel-opds/src/network.rs | 2 +- crates/citadel-opds/src/password.rs | 9 +- .../citadel-opds/src/service/credentials.rs | 99 ++-- crates/citadel-opds/src/service/mod.rs | 343 +++++++---- ...0003-opds-credentials-are-machine-local.md | 2 +- .../0004-two-mode-sharing-state-machine.md | 2 + ...-opds-credentials-are-stored-reversibly.md | 25 + src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 2 + src-tauri/src/menu.rs | 2 +- src-tauri/src/opds/commands.rs | 23 +- src-tauri/src/state.rs | 12 +- src/bindings.ts | 38 +- 18 files changed, 624 insertions(+), 595 deletions(-) create mode 100644 docs/adr/0005-opds-credentials-are-stored-reversibly.md diff --git a/Cargo.lock b/Cargo.lock index fce63efc..abbd9b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,18 +106,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash 0.5.0", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -393,15 +381,6 @@ 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" @@ -678,22 +657,20 @@ dependencies = [ name = "citadel-opds" version = "0.1.0" dependencies = [ - "argon2", "axum", "base64 0.22.1", "bytes", "chrono", "diesel", "futures-util", - "hmac", "libcalibre", + "log", "netdev", "quick-xml 0.38.4", "rand_core 0.6.4", "reqwest 0.12.28", "serde", "serde_json", - "sha2", "socket2", "specta", "subtle", @@ -708,6 +685,7 @@ dependencies = [ name = "citadel-rs" version = "0.6.1" dependencies = [ + "axum", "chrono", "citadel-core", "citadel-opds", @@ -3540,17 +3518,6 @@ dependencies = [ "subtle", ] -[[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" @@ -3571,7 +3538,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash 0.4.2", + "password-hash", "sha2", ] diff --git a/crates/citadel-opds/Cargo.toml b/crates/citadel-opds/Cargo.toml index d2fae931..f2aff40b 100644 --- a/crates/citadel-opds/Cargo.toml +++ b/crates/citadel-opds/Cargo.toml @@ -6,20 +6,18 @@ rust-version.workspace = true description = "OPDS catalog, authentication, and networking runtime for Citadel" [dependencies] -argon2 = "0.5" axum = "0.8.9" base64 = "0.22" bytes = "1" chrono = { version = "0.4.31", features = ["serde"] } futures-util = "0.3" -hmac = "0.12" libcalibre = { path = "../libcalibre" } +log = "0.4" netdev = { version = "=0.45.0", default-features = false } quick-xml = "0.38" serde_json = "1.0" socket2 = "0.6" rand_core = { version = "0.6", features = ["getrandom"] } -sha2 = "0.10" subtle = "2.6" serde = { version = "1.0", features = ["derive"] } specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] } diff --git a/crates/citadel-opds/src/auth.rs b/crates/citadel-opds/src/auth.rs index 4552da31..18ca31cf 100644 --- a/crates/citadel-opds/src/auth.rs +++ b/crates/citadel-opds/src/auth.rs @@ -1,12 +1,18 @@ +//! HTTP Basic authentication for the OPDS server. +//! +//! The credential snapshot is hot-swappable: listeners read it per request, +//! so rotating or replacing credentials never interrupts a running share +//! (see ADR 0005 for why the secret is stored reversibly). Consecutive +//! rejections back off exponentially, which is the online-guessing defense. + use std::{ - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, time::{Duration, Instant}, }; -use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Algorithm, Argon2, Params, Version, -}; use axum::{ extract::{Request, State}, http::{ @@ -17,79 +23,19 @@ use axum::{ response::{IntoResponse, Response}, }; use base64::{engine::general_purpose::STANDARD, Engine}; -use hmac::{Hmac, Mac}; -use rand_core::{OsRng, RngCore}; -use sha2::Sha256; use subtle::ConstantTimeEq; -const DEFAULT_CACHE_CAPACITY: usize = 256; -const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30); -const DEFAULT_TARGET_DURATION: Duration = Duration::from_millis(250); -/// Caps parallel Argon2 verifications. Hashing is tuned to be expensive; -/// unbounded parallelism lets one client make every request slow. -const ARGON2_CONCURRENCY: usize = 3; const BASIC_CHALLENGE: &str = "Basic realm=\"Citadel\", charset=\"UTF-8\""; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct OpdsAuthCredentials { pub username: String, - pub verifier: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum OpdsAuthError { - InvalidVerifier, - PasswordHashingFailed, -} - -impl std::fmt::Display for OpdsAuthError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidVerifier => { - formatter.write_str("The OPDS credential verifier is invalid.") - } - Self::PasswordHashingFailed => { - formatter.write_str("Could not create OPDS credentials.") - } - } - } -} - -impl std::error::Error for OpdsAuthError {} - -/// Creates credentials which store only a username and an Argon2id PHC verifier. -pub(crate) fn create_credentials( - username: String, - password: &[u8], -) -> Result { - let salt = SaltString::generate(&mut OsRng); - let verifier = argon2id() - .hash_password(password, &salt) - .map_err(|_| OpdsAuthError::PasswordHashingFailed)? - .to_string(); - - Ok(OpdsAuthCredentials { username, verifier }) -} - -#[derive(Clone)] -pub struct OpdsBasicAuth { - enabled: Option>, -} - -struct EnabledAuth { - credentials: OpdsAuthCredentials, - cache_key: [u8; 32], - cache: Mutex, - target_duration: Mutex, - /// Caps concurrent Argon2 verifications; hashing is deliberately expensive - /// and unbounded parallelism would make every request slow. - permits: Arc, - backoff: Mutex, + pub password: String, } /// Global exponential backoff on consecutive rejected attempts. With a -/// ~30.5-bit generated password, throttling the online attacker to ~1 guess -/// per second is what turns the margin into years. +/// ~30.5-bit generated password, throttling the online attacker to a couple +/// of guesses per second is what turns the margin into years. #[derive(Default)] struct Backoff { consecutive_failures: u32, @@ -101,7 +47,7 @@ const BACKOFF_BASE: Duration = Duration::from_secs(2); const BACKOFF_CAP: Duration = Duration::from_secs(60); impl Backoff { - /// Some(_) while the attacker is being refused without a hash. + /// Some(_) while the attacker is being refused without a check. fn refused_until(&self, now: Instant) -> Option { self.until .filter(|until| *until > now) @@ -114,7 +60,7 @@ impl Backoff { let shift = self .consecutive_failures .saturating_sub(BACKOFF_START_THRESHOLD) - .min(5) as u32; + .min(5); self.until = Some(now + BACKOFF_BASE.saturating_mul(1 << shift).min(BACKOFF_CAP)); } } @@ -124,187 +70,98 @@ impl Backoff { } } -#[derive(Clone, Copy)] -enum CachedOutcome { - Authorized, - Rejected, -} - -struct CacheEntry { - tag: [u8; 32], - outcome: CachedOutcome, - target_duration: Duration, - expires_at: Instant, +/// One instance lives as long as the service; listeners clone the handle, so +/// `set_required` (per start) and `swap_credentials` (per credential change) +/// are observed by everything serving. +#[derive(Clone)] +pub struct OpdsBasicAuth { + inner: Arc, } -struct AuthCache { - entries: Vec, - capacity: usize, - ttl: Duration, +struct AuthInner { + required: AtomicBool, + credentials: Mutex>>, + backoff: Mutex, } -impl AuthCache { - fn new(capacity: usize, ttl: Duration) -> Self { - Self { - entries: Vec::with_capacity(capacity), - capacity, - ttl, - } - } - - fn get(&mut self, tag: &[u8; 32]) -> Option<(CachedOutcome, Duration)> { - let now = Instant::now(); - self.entries.retain(|entry| entry.expires_at > now); - self.entries - .iter() - .find(|entry| bool::from(entry.tag.ct_eq(tag))) - .map(|entry| (entry.outcome, entry.target_duration)) - } - - fn insert(&mut self, tag: [u8; 32], outcome: CachedOutcome, target_duration: Duration) { - if self.capacity == 0 { - return; - } - if self.entries.len() == self.capacity { - self.entries.remove(0); - } - self.entries.push(CacheEntry { - tag, - outcome, - target_duration, - expires_at: Instant::now() + self.ttl, - }); +impl Default for OpdsBasicAuth { + fn default() -> Self { + Self::new() } } impl OpdsBasicAuth { - pub(crate) fn disabled() -> Self { - Self { enabled: None } + /// The natural constructor for embedders: an auth handle that requires + /// nothing and trusts no one until `set_required`/`swap_credentials`. + pub fn new() -> Self { + Self { + inner: Arc::new(AuthInner { + required: AtomicBool::new(false), + credentials: Mutex::new(None), + backoff: Mutex::new(Backoff::default()), + }), + } } - pub(crate) fn enabled(credentials: OpdsAuthCredentials) -> Result { - Self::enabled_with_settings( - credentials, - DEFAULT_CACHE_CAPACITY, - DEFAULT_CACHE_TTL, - DEFAULT_TARGET_DURATION, - ) + pub(crate) fn set_required(&self, required: bool) { + self.inner.required.store(required, Ordering::Relaxed); } - fn enabled_with_settings( - credentials: OpdsAuthCredentials, - cache_capacity: usize, - cache_ttl: Duration, - target_duration: Duration, - ) -> Result { - let password_hash = - PasswordHash::new(&credentials.verifier).map_err(|_| OpdsAuthError::InvalidVerifier)?; - if password_hash.algorithm.as_str() != "argon2id" { - return Err(OpdsAuthError::InvalidVerifier); - } - - let mut cache_key = [0; 32]; - OsRng.fill_bytes(&mut cache_key); - Ok(Self { - enabled: Some(Arc::new(EnabledAuth { - credentials, - cache_key, - cache: Mutex::new(AuthCache::new(cache_capacity, cache_ttl)), - target_duration: Mutex::new(target_duration), - permits: Arc::new(tokio::sync::Semaphore::new(ARGON2_CONCURRENCY)), - backoff: Mutex::new(Backoff::default()), - })), - }) + #[cfg(test)] + pub(crate) fn required(&self) -> bool { + self.inner.required.load(Ordering::Relaxed) } - fn is_enabled(&self) -> bool { - self.enabled.is_some() + pub(crate) fn swap_credentials(&self, credentials: Option) { + *self.inner.credentials.lock().unwrap() = credentials.map(Arc::new); } - /// Authorizes complete Authorization header bytes. Disabled authentication does not parse them. - async fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome { - let Some(enabled) = &self.enabled else { + fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome { + if !self.inner.required.load(Ordering::Relaxed) { return AuthOutcome::Authorized; - }; + } { - let backoff = enabled.backoff.lock().unwrap(); + let backoff = self.inner.backoff.lock().unwrap(); if backoff.refused_until(Instant::now()).is_some() { return AuthOutcome::Backoff; } } - let started_at = Instant::now(); - let authorization = authorization.unwrap_or_default(); - let tag = opaque_tag(&enabled.cache_key, authorization); - if let Some((outcome, cached_duration)) = enabled - .cache - .lock() - .ok() - .and_then(|mut cache| cache.get(&tag)) + let credentials = self.inner.credentials.lock().unwrap().clone(); + let parsed = authorization.and_then(parse_basic_credentials); + let authorized = parsed + .is_some_and(|provided| credentials.is_some_and(|stored| stored.matches(&provided))); { - let target_duration = current_target_duration(enabled).max(cached_duration); - { - let mut backoff = enabled.backoff.lock().unwrap(); - match outcome { - CachedOutcome::Authorized => backoff.note_success(), - CachedOutcome::Rejected => backoff.note_rejection(Instant::now()), - } + let mut backoff = self.inner.backoff.lock().unwrap(); + if authorized { + backoff.note_success(); + } else { + backoff.note_rejection(Instant::now()); } - pad_to_target(started_at, target_duration).await; - return match outcome { - CachedOutcome::Authorized => AuthOutcome::Authorized, - CachedOutcome::Rejected => AuthOutcome::Rejected, - }; } - - // Cap parallel Argon2 work: hashing is deliberately expensive, and - // unbounded parallelism lets one client make every request slow. - let permit = match enabled.permits.clone().try_acquire_owned() { - Ok(permit) => permit, - Err(_) => return AuthOutcome::Busy, - }; - let parsed = parse_basic_credentials(authorization); - let authorized = match parsed { - Some(credentials) if credentials.username == enabled.credentials.username => { - let verifier = enabled.credentials.verifier.clone(); - tokio::task::spawn_blocking(move || { - verify_password(&verifier, &credentials.password) - }) - .await - .unwrap_or(false) - } - _ => false, - }; - drop(permit); - let target_duration = target_duration(enabled, started_at.elapsed()); - let outcome = if authorized { - CachedOutcome::Authorized + if authorized { + AuthOutcome::Authorized } else { - CachedOutcome::Rejected - }; - if let Ok(mut cache) = enabled.cache.lock() { - cache.insert(tag, outcome, target_duration); - } - { - let mut backoff = enabled.backoff.lock().unwrap(); - match outcome { - CachedOutcome::Authorized => backoff.note_success(), - CachedOutcome::Rejected => backoff.note_rejection(Instant::now()), - } - } - pad_to_target(started_at, target_duration).await; - match outcome { - CachedOutcome::Authorized => AuthOutcome::Authorized, - CachedOutcome::Rejected => AuthOutcome::Rejected, + AuthOutcome::Rejected } } } +impl OpdsAuthCredentials { + /// Constant-time on both fields so response timing never reveals which + /// characters of a guess were right. (Length is still observable from + /// the compare itself; at 30.5 bits of generated entropy that is noise.) + fn matches(&self, provided: &BasicCredentials) -> bool { + let username = self.username.as_bytes().ct_eq(provided.username.as_bytes()); + let password = self.password.as_bytes().ct_eq(&provided.password); + bool::from(username & password) + } +} + enum AuthOutcome { Authorized, Rejected, - Busy, Backoff, } @@ -314,7 +171,7 @@ pub(crate) async fn require_basic_auth( request: Request, next: Next, ) -> Response { - if !auth.is_enabled() { + if !auth.inner.required.load(Ordering::Relaxed) { return next.run(request).await; } @@ -322,33 +179,17 @@ pub(crate) async fn require_basic_auth( .headers() .get(AUTHORIZATION) .map(|value| value.as_bytes().to_vec()); - match auth.authorize(authorization.as_deref()).await { + match auth.authorize(authorization.as_deref()) { AuthOutcome::Authorized => next.run(request).await, AuthOutcome::Backoff => ( StatusCode::TOO_MANY_REQUESTS, [(RETRY_AFTER, HeaderValue::from_static("1"))], ) .into_response(), - AuthOutcome::Busy => ( - StatusCode::SERVICE_UNAVAILABLE, - [ - (RETRY_AFTER, HeaderValue::from_static("1")), - (WWW_AUTHENTICATE, HeaderValue::from_static(BASIC_CHALLENGE)), - ], - ) - .into_response(), AuthOutcome::Rejected => unauthorized_response(), } } -fn current_target_duration(enabled: &EnabledAuth) -> Duration { - enabled - .target_duration - .lock() - .map(|duration| *duration) - .unwrap_or(DEFAULT_TARGET_DURATION) -} - struct BasicCredentials { username: String, password: Vec, @@ -369,38 +210,6 @@ fn parse_basic_credentials(authorization: &[u8]) -> Option { }) } -fn verify_password(verifier: &str, password: &[u8]) -> bool { - let Ok(password_hash) = PasswordHash::new(verifier) else { - return false; - }; - password_hash.algorithm.as_str() == "argon2id" - && argon2id().verify_password(password, &password_hash).is_ok() -} - -fn argon2id() -> Argon2<'static> { - Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) -} - -fn opaque_tag(cache_key: &[u8; 32], authorization: &[u8]) -> [u8; 32] { - let mut mac = Hmac::::new_from_slice(cache_key).expect("HMAC accepts a fixed-size key"); - mac.update(authorization); - mac.finalize().into_bytes().into() -} - -fn target_duration(enabled: &EnabledAuth, elapsed: Duration) -> Duration { - let Ok(mut target_duration) = enabled.target_duration.lock() else { - return elapsed.max(DEFAULT_TARGET_DURATION); - }; - *target_duration = (*target_duration).max(elapsed); - *target_duration -} - -async fn pad_to_target(started_at: Instant, target_duration: Duration) { - if let Some(remaining) = target_duration.checked_sub(started_at.elapsed()) { - tokio::time::sleep(remaining).await; - } -} - fn unauthorized_response() -> Response { ( StatusCode::UNAUTHORIZED, @@ -429,14 +238,14 @@ mod tests { format!("Basic {}", STANDARD.encode(credentials)).into_bytes() } - fn enabled_auth(username: &str, password: &[u8], target_duration: Duration) -> OpdsBasicAuth { - OpdsBasicAuth::enabled_with_settings( - create_credentials(username.to_string(), password).unwrap(), - 2, - Duration::from_secs(1), - target_duration, - ) - .unwrap() + fn enabled_auth(username: &str, password: &str) -> OpdsBasicAuth { + let auth = OpdsBasicAuth::new(); + auth.set_required(true); + auth.swap_credentials(Some(OpdsAuthCredentials { + username: username.to_string(), + password: password.to_string(), + })); + auth } fn app(auth: OpdsBasicAuth) -> Router { @@ -456,60 +265,46 @@ mod tests { .unwrap() } - #[test] - fn credentials_store_a_random_argon2id_phc_verifier() { - let first = create_credentials("reader".to_string(), b"correct horse").unwrap(); - let second = create_credentials("reader".to_string(), b"correct horse").unwrap(); - - assert_eq!(first.username, "reader"); - assert!(first.verifier.starts_with("$argon2id$")); - assert_ne!(first.verifier, second.verifier); - let password_hash = PasswordHash::new(&first.verifier).unwrap(); - assert!(argon2id() - .verify_password(b"correct horse", &password_hash) - .is_ok()); - } - #[tokio::test] async fn repeated_failures_throttle_into_a_backoff_window() { - let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let auth = enabled_auth("reader", "correct horse"); let wrong = Some(&basic_header("reader", b"wrong password")[..]); for _ in 0..BACKOFF_START_THRESHOLD { - assert!(matches!(auth.authorize(wrong).await, AuthOutcome::Rejected)); + assert!(matches!(auth.authorize(wrong), AuthOutcome::Rejected)); } - // Next attempt is refused without a verification pass. - assert!(matches!(auth.authorize(wrong).await, AuthOutcome::Backoff)); + // The next attempt is refused without a credential check. + assert!(matches!(auth.authorize(wrong), AuthOutcome::Backoff)); - // Backoff expires and verification resumes. + // Backoff expires and checks resume. tokio::time::sleep(BACKOFF_BASE).await; - assert!(matches!(auth.authorize(wrong).await, AuthOutcome::Rejected)); + assert!(matches!(auth.authorize(wrong), AuthOutcome::Rejected)); } #[tokio::test] async fn successful_authentication_resets_the_backoff_budget() { - let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let auth = enabled_auth("reader", "correct horse"); let right = Some(&basic_header("reader", b"correct horse")[..]); let wrong = Some(&basic_header("reader", b"wrong password")[..]); for _ in 0..BACKOFF_START_THRESHOLD.saturating_sub(1) { - assert!(matches!(auth.authorize(wrong).await, AuthOutcome::Rejected)); + assert!(matches!(auth.authorize(wrong), AuthOutcome::Rejected)); } - assert!(matches!( - auth.authorize(right).await, - AuthOutcome::Authorized - )); - // The failure streak was reset: more failures are answered (slowly), - // not refused. - assert!(matches!(auth.authorize(wrong).await, AuthOutcome::Rejected)); + assert!(matches!(auth.authorize(right), AuthOutcome::Authorized)); + // The failure streak was reset: more failures are answered, not refused. + assert!(matches!(auth.authorize(wrong), AuthOutcome::Rejected)); } #[tokio::test] - async fn disabled_auth_bypasses_even_malformed_authorization() { - let auth = OpdsBasicAuth::disabled(); + async fn auth_not_required_bypasses_even_malformed_authorization() { + let auth = OpdsBasicAuth::new(); + auth.swap_credentials(Some(OpdsAuthCredentials { + username: "reader".to_string(), + password: "correct horse".to_string(), + })); assert!(matches!( - auth.authorize(Some(b"not even close to Basic")).await, + auth.authorize(Some(b"not even close to Basic")), AuthOutcome::Authorized )); assert_eq!( @@ -520,31 +315,68 @@ mod tests { ); } + #[tokio::test] + async fn required_auth_with_no_credentials_rejects_everything() { + let auth = OpdsBasicAuth::new(); + auth.set_required(true); + + assert!(matches!( + auth.authorize(Some(&basic_header("reader", b"anything"))), + AuthOutcome::Rejected + )); + assert!(matches!(auth.authorize(None), AuthOutcome::Rejected)); + } + #[tokio::test] async fn enabled_auth_accepts_only_the_configured_basic_credentials() { - let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let auth = enabled_auth("reader", "correct horse"); assert!(matches!( - auth.authorize(Some(&basic_header("reader", b"correct horse"))) - .await, + auth.authorize(Some(&basic_header("reader", b"correct horse"))), AuthOutcome::Authorized )); assert!(matches!( - auth.authorize(Some(&basic_header("reader", b"wrong password"))) - .await, + auth.authorize(Some(&basic_header("reader", b"wrong password"))), AuthOutcome::Rejected )); assert!(matches!( - auth.authorize(Some(&basic_header("someone-else", b"correct horse"))) - .await, + auth.authorize(Some(&basic_header("someone-else", b"correct horse"))), AuthOutcome::Rejected )); - assert!(matches!(auth.authorize(None).await, AuthOutcome::Rejected)); + assert!(matches!(auth.authorize(None), AuthOutcome::Rejected)); + } + + #[tokio::test] + async fn swapping_credentials_takes_effect_on_the_next_request_without_a_restart() { + let auth = enabled_auth("reader", "first-secret"); + let old = basic_header("reader", b"first-secret"); + let new = basic_header("reader", b"second-secret"); + + assert!(matches!( + auth.authorize(Some(&old)), + AuthOutcome::Authorized + )); + + auth.swap_credentials(Some(OpdsAuthCredentials { + username: "reader".to_string(), + password: "second-secret".to_string(), + })); + + assert!(matches!(auth.authorize(Some(&old)), AuthOutcome::Rejected)); + assert!(matches!( + auth.authorize(Some(&new)), + AuthOutcome::Authorized + )); + + // A clone of the handle shares the same snapshot. + let clone = auth.clone(); + auth.swap_credentials(None); + assert!(matches!(clone.authorize(Some(&new)), AuthOutcome::Rejected)); } #[tokio::test] async fn middleware_challenges_missing_and_wrong_credentials_and_allows_correct_ones() { - let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let auth = enabled_auth("reader", "correct horse"); let missing = response(auth.clone(), None).await; let wrong = response( @@ -565,57 +397,17 @@ mod tests { } #[tokio::test] - async fn cache_records_and_reuses_the_authorization_outcome_without_raw_header_bytes() { - let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); - let header = basic_header("reader", b"correct horse"); - - assert!(matches!( - auth.authorize(Some(&header)).await, - AuthOutcome::Authorized - )); - let enabled = auth.enabled.as_ref().unwrap(); - let cache = enabled.cache.lock().unwrap(); - assert_eq!(cache.entries.len(), 1); - assert_eq!( - cache.entries[0].tag, - opaque_tag(&enabled.cache_key, &header) - ); - assert!(matches!( - cache.entries[0].outcome, - CachedOutcome::Authorized - )); - drop(cache); - - assert!(matches!( - auth.authorize(Some(&header)).await, - AuthOutcome::Authorized - )); - assert_eq!(enabled.cache.lock().unwrap().entries.len(), 1); - } - - #[tokio::test] - async fn unknown_usernames_and_cached_rejections_are_padded_to_the_target_duration() { - let target_duration = Duration::from_millis(20); - let auth = enabled_auth("reader", b"correct horse", target_duration); - let unknown_header = basic_header("unknown", b"correct horse"); - - let first_started_at = Instant::now(); - assert!(matches!( - auth.authorize(Some(&unknown_header)).await, - AuthOutcome::Rejected - )); - let first_elapsed = first_started_at.elapsed(); + async fn backoff_is_answered_with_too_many_requests_and_a_retry_hint() { + let auth = enabled_auth("reader", "correct horse"); + let wrong = Some(&basic_header("reader", b"wrong password")[..]); - let cached_started_at = Instant::now(); - assert!(matches!( - auth.authorize(Some(&unknown_header)).await, - AuthOutcome::Rejected - )); - let cached_elapsed = cached_started_at.elapsed(); + for _ in 0..BACKOFF_START_THRESHOLD { + assert!(matches!(auth.authorize(wrong), AuthOutcome::Rejected)); + } - let minimum_padded_duration = target_duration - Duration::from_millis(2); - assert!(first_elapsed >= minimum_padded_duration); - assert!(cached_elapsed >= minimum_padded_duration); + let refused = response(auth, Some(basic_header("reader", b"wrong password"))).await; + assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(refused.headers().get(RETRY_AFTER).unwrap(), "1"); } #[test] @@ -627,28 +419,6 @@ mod tests { assert_eq!(credentials.password, b"pa:ss:word"); } - #[test] - fn cache_uses_only_opaque_complete_header_tags_and_is_bounded() { - let key = [7; 32]; - let first = opaque_tag(&key, b"Basic cmVhZGVyOmZpcnN0"); - let second = opaque_tag(&key, b"Basic cmVhZGVyOnNlY29uZA=="); - let differently_cased = opaque_tag(&key, b"basic cmVhZGVyOmZpcnN0"); - assert_ne!(first, second); - assert_ne!(first, differently_cased); - - let mut cache = AuthCache::new(1, Duration::from_secs(1)); - cache.insert(first, CachedOutcome::Authorized, Duration::from_millis(10)); - cache.insert(second, CachedOutcome::Rejected, Duration::from_millis(20)); - - assert!(cache.get(&first).is_none()); - assert_eq!( - cache - .get(&second) - .map(|(_, target_duration)| target_duration), - Some(Duration::from_millis(20)) - ); - } - #[test] fn unauthorized_response_uses_the_koreader_basic_challenge() { let response = unauthorized_response(); diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs index bb66a402..1bdcb206 100644 --- a/crates/citadel-opds/src/catalog.rs +++ b/crates/citadel-opds/src/catalog.rs @@ -685,7 +685,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let task = tokio::spawn(async move { - axum::serve(listener, router(source, OpdsBasicAuth::disabled())) + axum::serve(listener, router(source, OpdsBasicAuth::new())) .await .unwrap(); }); diff --git a/crates/citadel-opds/src/credential_store.rs b/crates/citadel-opds/src/credential_store.rs index b1a7b1ed..20403492 100644 --- a/crates/citadel-opds/src/credential_store.rs +++ b/crates/citadel-opds/src/credential_store.rs @@ -1,4 +1,9 @@ //! Persistence for OPDS sharing credentials. +//! +//! The secret is stored reversibly and that is deliberate — the reader UI +//! reveals it on demand, so rotation is never forced (ADR 0005). The file is +//! 0600; a same-user attacker who can read it can already read every book in +//! the library, so hashing buys nothing here. use std::{ fs::{self, OpenOptions}, @@ -13,7 +18,7 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct StoredOpdsCredentials { pub username: String, - pub password_verifier: String, + pub password: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] @@ -23,6 +28,15 @@ pub struct OpdsCredentialStatus { pub username: Option, } +/// The stored secret, for the reader UI's reveal flow. The password is kept +/// reversibly on purpose (ADR 0005); the app process may show it to its user. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsCredentialSecret { + pub username: String, + pub password: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct GeneratedOpdsCredentials { @@ -43,7 +57,17 @@ struct CredentialStoreInner { impl OpdsCredentialStore { pub fn load(path: PathBuf) -> io::Result { let credentials = match fs::read(&path) { - Ok(contents) => Some(serde_json::from_slice(&contents).map_err(io::Error::other)?), + // Unparseable contents (e.g. pre-reveal verifier files from a + // nightly) mean no usable credentials; regeneration is the path + // forward, so treat them as absent — but log it, because genuine + // corruption deserves to be visible too. + Ok(contents) => match serde_json::from_slice(&contents) { + Ok(credentials) => Some(credentials), + Err(error) => { + log::warn!("Ignoring unreadable OPDS credential file ({error}); regenerate reader sign-in."); + None + } + }, Err(error) if error.kind() == io::ErrorKind::NotFound => None, Err(error) => return Err(error), }; @@ -95,6 +119,13 @@ impl OpdsCredentialStore { self.read().clone() } + pub fn secret(&self) -> Option { + self.get().map(|credentials| OpdsCredentialSecret { + username: credentials.username, + password: credentials.password, + }) + } + pub fn set(&self, credentials: StoredOpdsCredentials) -> io::Result<()> { if let Some(path) = &self.inner.path { persist(path, &credentials)?; @@ -150,28 +181,27 @@ mod tests { use super::*; #[test] - fn persists_only_username_and_verifier_with_private_permissions() { + fn persists_username_and_password_with_private_permissions() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("opds-credentials.json"); let store = OpdsCredentialStore::load(path.clone()).unwrap(); store .set(StoredOpdsCredentials { username: "reader".to_string(), - password_verifier: "$argon2id$verifier".to_string(), + password: "wren724=wolf".to_string(), }) .unwrap(); let contents = fs::read_to_string(&path).unwrap(); assert!(contents.contains("reader")); - assert!(contents.contains("$argon2id$verifier")); - assert!(!contents.contains("password\":")); + assert!(contents.contains("wren724=wolf")); assert_eq!( OpdsCredentialStore::load(path) .unwrap() - .status() - .username - .as_deref(), - Some("reader") + .get() + .unwrap() + .password, + "wren724=wolf" ); #[cfg(unix)] @@ -188,6 +218,26 @@ mod tests { } } + #[test] + fn legacy_verifier_files_are_treated_as_unconfigured() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opds-credentials.json"); + fs::write( + &path, + r#"{"username":"reader","passwordVerifier":"$argon2id$abc"}"#, + ) + .unwrap(); + + let store = OpdsCredentialStore::load(path).unwrap(); + assert_eq!( + store.status(), + OpdsCredentialStatus { + configured: false, + username: None + } + ); + } + #[test] fn clear_is_idempotent() { let directory = tempfile::tempdir().unwrap(); @@ -196,7 +246,7 @@ mod tests { store .set(StoredOpdsCredentials { username: "reader".to_string(), - password_verifier: "verifier".to_string(), + password: "wren724=wolf".to_string(), }) .unwrap(); diff --git a/crates/citadel-opds/src/network.rs b/crates/citadel-opds/src/network.rs index a3722f10..94c3c7f7 100644 --- a/crates/citadel-opds/src/network.rs +++ b/crates/citadel-opds/src/network.rs @@ -283,7 +283,7 @@ impl WaitingReason { /// credentials (see the auth integration) since it serves every network the /// computer can reach. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] -#[serde(rename_all = "camelCase", tag = "type")] +#[serde(rename_all = "camelCase")] pub enum OpdsBindTarget { LocalNetworks, AllInterfaces, diff --git a/crates/citadel-opds/src/password.rs b/crates/citadel-opds/src/password.rs index ccbc3e62..794434f9 100644 --- a/crates/citadel-opds/src/password.rs +++ b/crates/citadel-opds/src/password.rs @@ -3,8 +3,8 @@ use super::words::WORD_POOL; use serde::Serialize; -/// Returned once when credentials are generated; the plaintext is never -/// stored, so this is the only chance to copy it into a reader. +/// Returned when credentials are generated; the password is also stored +/// reversibly (ADR 0005) so the UI can reveal it again later. #[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct GeneratedOpdsCredentials { @@ -19,9 +19,8 @@ pub(crate) const PASSWORD_SYMBOLS: &[char] = &['!', '*', '-', '=', '~', '$']; /// `word` + three digits (2-9) + one symbol + `word`, e.g. `wren724=wolf`. /// Lowercase words from a curated pool, digits without 0/1, symbols from a /// URL-safe set (`! * - = ~ $`) so readers that paste credentials into -/// `http://user:pass@host/` logins cannot mangle them. Roughly 30.5 bits: an -/// online-only attacker faces an Argon2-slowed endpoint, and auth failures -/// back off. +/// `http://user:pass@host/` logins cannot mangle them. Roughly 30.5 bits: +/// an online-only attacker faces exponential backoff on failures. /// Symbols that survive e-reader input fields and `user:pass@host` logins. pub(crate) fn generate_password() -> String { use rand_core::{OsRng, RngCore}; diff --git a/crates/citadel-opds/src/service/credentials.rs b/crates/citadel-opds/src/service/credentials.rs index ebba003d..333d62d6 100644 --- a/crates/citadel-opds/src/service/credentials.rs +++ b/crates/citadel-opds/src/service/credentials.rs @@ -2,81 +2,94 @@ //! A child of `service` so it can reach the service's private state without //! visibility holes. -use super::super::auth::create_credentials; +use super::super::auth::OpdsAuthCredentials; use super::super::password::{generate_password, GeneratedOpdsCredentials}; use super::OpdsService; -use crate::credential_store::{OpdsCredentialStatus, StoredOpdsCredentials}; +use crate::credential_store::{OpdsCredentialSecret, OpdsCredentialStatus, StoredOpdsCredentials}; use crate::{OpdsErrorCode, OpdsStatusError}; +const USERNAME_FORBIDDEN: char = ':'; + +fn store_error(error: std::io::Error) -> OpdsStatusError { + OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + } +} + impl OpdsService { pub fn credential_status(&self) -> OpdsCredentialStatus { self.inner.credentials.status() } - pub async fn configure_credentials( + pub fn credential_secret(&self) -> Option { + self.inner.credentials.secret() + } + + /// Stores new credentials and swaps the live auth snapshot. A running + /// share keeps running; the next request already checks against the new + /// secret. + pub fn configure_credentials( &self, username: String, password: String, ) -> Result { - self.stop_if_active().await; - let credentials = - create_credentials(username, password.as_bytes()).map_err(|error| OpdsStatusError { + if username.trim().is_empty() || password.is_empty() { + return Err(OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: "Reader sign-in needs a non-empty username and password.".to_string(), + }); + } + // HTTP Basic splits `user:pass` at the first colon, so a colon in the + // username could never authenticate. + if username.contains(USERNAME_FORBIDDEN) { + return Err(OpdsStatusError { code: OpdsErrorCode::Unexpected, - message: error.to_string(), - })?; + message: "Usernames can't contain a colon (:).".to_string(), + }); + } let credentials = StoredOpdsCredentials { - username: credentials.username, - password_verifier: credentials.verifier, + username: username.trim().to_string(), + password, }; self.inner .credentials .set(credentials) - .map_err(|error| OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: error.to_string(), - })?; + .map_err(store_error)?; + self.swap_auth_from_store(); Ok(self.credential_status()) } - pub async fn generate_credentials( + pub fn generate_credentials( &self, username: String, ) -> Result { - self.stop_if_active().await; let password = generate_password(); - let credentials = - create_credentials(username.clone(), password.as_bytes()).map_err(|error| { - OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: error.to_string(), - } - })?; - let credentials = StoredOpdsCredentials { - username: credentials.username, - password_verifier: credentials.verifier, - }; - self.inner - .credentials - .set(credentials) - .map_err(|error| OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: error.to_string(), - })?; + self.configure_credentials(username.clone(), password.clone())?; Ok(GeneratedOpdsCredentials { username, password }) } /// Clearing credentials while sharing is gated on them must stop sharing: - /// the running server would keep accepting the password that no longer - /// exists, or worse, keep its auth layer pointed at stale material. + /// the running server's bind policy advertises credential-gated reach, + /// and a snapshot with no secret could only reject everyone. pub async fn clear_credentials(&self) -> Result { self.stop_if_active().await; - self.inner - .credentials - .clear() - .map_err(|error| OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: error.to_string(), - })?; + self.inner.credentials.clear().map_err(store_error)?; + self.swap_auth_from_store(); Ok(self.credential_status()) } } + +impl OpdsService { + pub(super) fn swap_auth_from_store(&self) { + let snapshot = self + .inner + .credentials + .get() + .map(|credentials| OpdsAuthCredentials { + username: credentials.username, + password: credentials.password, + }); + self.inner.auth.swap_credentials(snapshot); + } +} diff --git a/crates/citadel-opds/src/service/mod.rs b/crates/citadel-opds/src/service/mod.rs index 0241793a..5b173c6d 100644 --- a/crates/citadel-opds/src/service/mod.rs +++ b/crates/citadel-opds/src/service/mod.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use tokio::{sync::oneshot, task::JoinHandle}; use super::{ - auth::{OpdsAuthCredentials, OpdsBasicAuth}, + auth::OpdsBasicAuth, credential_store::OpdsCredentialStore, network::{ advertised_url, plan_bindings, BindPolicy, InterfaceProvider, InterfaceSnapshot, @@ -72,6 +72,9 @@ pub struct OpdsServiceStatus { pub active_library_id: Option, pub urls: Vec, pub error: Option, + /// The configuration the server is actually running with, so the UI can + /// show the live port and scope instead of a possibly-stale draft. + pub config: Option, } impl Default for OpdsServiceStatus { @@ -81,6 +84,7 @@ impl Default for OpdsServiceStatus { active_library_id: None, urls: Vec::new(), error: None, + config: None, } } } @@ -238,9 +242,7 @@ fn bind_failure_error(address: SocketAddr, class: BindFailureClass) -> OpdsStatu match class { BindFailureClass::Persistent => OpdsStatusError { code: OpdsErrorCode::PortUnavailable, - message: format!( - "The port Citadel uses for {address} is unavailable or restricted. Pick a different port." - ), + message: format!("Network address {address} is not available; please pick a new port."), }, BindFailureClass::Transient => OpdsStatusError { code: OpdsErrorCode::Unexpected, @@ -257,6 +259,11 @@ fn listener_failed_error() -> OpdsStatusError { } } +struct Preflight { + port: u16, + policy: BindPolicy, +} + /// The whole sharing lifecycle. Variants own their resources, so state and /// sockets cannot drift apart; transitions are the only place state changes, /// and every one matches explicitly. `gen` is a generation counter: events @@ -373,21 +380,29 @@ impl SharingState { impl From<&SharingState> for OpdsServiceStatus { fn from(state: &SharingState) -> Self { - let plain = - |state: OpdsLifecycleState, urls: Vec, error: Option| { - OpdsServiceStatus { - state, - active_library_id: None, - urls, - error, - } - }; + let plain = |state: OpdsLifecycleState, + urls: Vec, + error: Option, + config: Option| { + OpdsServiceStatus { + state, + active_library_id: None, + urls, + error, + config, + } + }; match state { - SharingState::Stopped => plain(OpdsLifecycleState::Stopped, Vec::new(), None), - SharingState::Starting { .. } => plain(OpdsLifecycleState::Starting, Vec::new(), None), - SharingState::Running { urls, .. } => { - plain(OpdsLifecycleState::Running, urls.clone(), None) + SharingState::Stopped => plain(OpdsLifecycleState::Stopped, Vec::new(), None, None), + SharingState::Starting { .. } => { + plain(OpdsLifecycleState::Starting, Vec::new(), None, None) } + SharingState::Running { urls, config, .. } => plain( + OpdsLifecycleState::Running, + urls.clone(), + None, + Some(config.clone()), + ), SharingState::Waiting { reason, .. } => plain( OpdsLifecycleState::WaitingForInterface, Vec::new(), @@ -395,10 +410,14 @@ impl From<&SharingState> for OpdsServiceStatus { code: OpdsErrorCode::InterfaceUnavailable, message: reason.message(), }), + None, + ), + SharingState::Failed { error, urls } => plain( + OpdsLifecycleState::Error, + urls.clone(), + Some(error.clone()), + None, ), - SharingState::Failed { error, urls } => { - plain(OpdsLifecycleState::Error, urls.clone(), Some(error.clone())) - } } } } @@ -426,6 +445,11 @@ struct ServiceInner { next_gen: std::sync::atomic::AtomicU64, source: Arc, credentials: OpdsCredentialStore, + auth: OpdsBasicAuth, + /// Serializes start/stop/reconfigure/clear across their await points so + /// two windows (or a window and a poll race) cannot interleave a stop + /// into a restart's drain. + op_lock: tokio::sync::Mutex<()>, dependencies: ServiceDependencies, } @@ -479,14 +503,17 @@ impl OpdsService { next_gen: AtomicU64::new(0), source, credentials, + auth: OpdsBasicAuth::new(), + op_lock: tokio::sync::Mutex::new(()), dependencies, }), } } - /// Any credential change while sharing is not Stopped stops sharing: the - /// running listeners (and a Waiting poll) hold an auth built from the old - /// credentials, and stale passwords must not keep working. + /// Clearing credentials while sharing is live stops sharing: the + /// all-networks bind policy exists only while credentials exist, and a + /// required auth snapshot with no secret could only reject everyone. + /// Configure and generate do NOT stop; they hot-swap the snapshot. async fn stop_if_active(&self) { let listeners = { let mut state = self.inner.state.lock().unwrap(); @@ -506,23 +533,38 @@ impl OpdsService { status } - pub async fn start( + /// Applies a changed port or scope to a live share: a deliberate restart + /// through the normal stop/start transitions, so binding rules and the + /// auth gate are re-checked in one place. Stopped shares just start. + pub async fn reconfigure( &self, config: OpdsStartConfig, ) -> Result { - let port = u16::try_from(config.port) - .ok() - .filter(|port| *port != 0) - .ok_or(OpdsStatusError { - code: OpdsErrorCode::InvalidPort, - message: "Choose a port between 1 and 65535.".to_string(), - })?; - if active_library_id(self.inner.source.clone()).is_none() { - return Err(OpdsStatusError { - code: OpdsErrorCode::LibraryNotReady, - message: "Open a library before starting sharing.".to_string(), - }); + let _guard = self.inner.op_lock.lock().await; + self.preflight(&config)?; + let listeners = { + let mut state = self.inner.state.lock().unwrap(); + state.stop() + }; + if let Some(listeners) = listeners { + listeners.drain().await; } + self.start_inner(config).await + } + + pub async fn start( + &self, + config: OpdsStartConfig, + ) -> Result { + let _guard = self.inner.op_lock.lock().await; + self.start_inner(config).await + } + + async fn start_inner( + &self, + config: OpdsStartConfig, + ) -> Result { + let Preflight { port, policy } = self.preflight(&config)?; let already_running = { let state = self.inner.state.lock().unwrap(); @@ -535,56 +577,25 @@ impl OpdsService { return Ok(self.status().await); } - let stored = if config.authentication_enabled { - let stored = self.inner.credentials.get(); - if stored.is_none() { - return Err(OpdsStatusError { - code: OpdsErrorCode::AuthRequired, - message: "Set a username and password to require them for sharing.".to_string(), - }); - } - stored - } else { - None - }; - let auth = match &stored { - Some(credentials) => OpdsBasicAuth::enabled(OpdsAuthCredentials { - username: credentials.username.clone(), - verifier: credentials.password_verifier.clone(), - }) - .map_err(|error| OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: error.to_string(), - })?, - None => OpdsBasicAuth::disabled(), - }; - // Credentials configured is the only thing that unlocks serving - // beyond the local network. - let policy = BindPolicy { - allow_global: stored.is_some(), - }; - if matches!(config.target, OpdsBindTarget::AllInterfaces) && !policy.allow_global { - // AllInterfaces serves every network the computer can reach; it - // exists to be paired with credentials. - return Err(OpdsStatusError { - code: OpdsErrorCode::AuthRequired, - message: "Sharing on all networks requires a username and password.".to_string(), - }); - } - let gen = self.inner.next_gen.fetch_add(1, Ordering::Relaxed) + 1; { let mut state = self.inner.state.lock().unwrap(); state.begin_start(gen)?; } + // The transition above committed us to starting; only now may the + // shared auth gate change — a rejected start must never mutate what + // live listeners serve. + self.inner.auth.set_required(config.authentication_enabled); + self.swap_auth_from_store(); + let outcome = attempt_bind( &self.inner.dependencies, self.inner.source.clone(), &config.target, port, policy, - auth, + self.inner.auth.clone(), ) .await; @@ -623,7 +634,59 @@ impl OpdsService { Ok(self.status().await) } + /// Every check a config must pass before any state changes, so + /// `reconfigure` can reject a bad config without stopping a live share. + fn preflight(&self, config: &OpdsStartConfig) -> Result { + let port = u16::try_from(config.port) + .ok() + .filter(|port| *port != 0) + .ok_or(OpdsStatusError { + code: OpdsErrorCode::InvalidPort, + message: "Choose a port between 1 and 65535.".to_string(), + })?; + if active_library_id(self.inner.source.clone()).is_none() { + return Err(OpdsStatusError { + code: OpdsErrorCode::LibraryNotReady, + message: "Open a library before starting sharing.".to_string(), + }); + } + + let stored = if config.authentication_enabled { + let stored = self.inner.credentials.get(); + if stored.is_none() { + return Err(OpdsStatusError { + code: OpdsErrorCode::AuthRequired, + message: "Reader sign-in needs a password. Generate or set one, then turn sharing on.".to_string(), + }); + } + stored + } else { + None + }; + // Credentials configured is the only thing that unlocks serving + // beyond the local network. + let policy = BindPolicy { + allow_global: stored.is_some(), + }; + if matches!(config.target, OpdsBindTarget::AllInterfaces) && !policy.allow_global { + // AllInterfaces serves every network the computer can reach; it + // exists to be paired with credentials. + return Err(OpdsStatusError { + code: OpdsErrorCode::AuthRequired, + message: "Sharing on all networks requires reader sign-in. Set a password first." + .to_string(), + }); + } + + Ok(Preflight { port, policy }) + } + pub async fn stop(&self) -> OpdsServiceStatus { + let _guard = self.inner.op_lock.lock().await; + self.stop_inner().await + } + + async fn stop_inner(&self) -> OpdsServiceStatus { let listeners = { let mut state = self.inner.state.lock().unwrap(); state.stop() @@ -674,7 +737,7 @@ impl OpdsService { &config.target, port, BindPolicy::default(), - OpdsBasicAuth::disabled(), + inner.auth.clone(), ) .await; @@ -1295,14 +1358,14 @@ mod tests { .start( SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), missing_source(), - OpdsBasicAuth::disabled(), + OpdsBasicAuth::new(), ) .expect("v4 wildcard bind"); let v6 = factory .start( SocketAddr::new(IpAddr::V6("::".parse().unwrap()), port), missing_source(), - OpdsBasicAuth::disabled(), + OpdsBasicAuth::new(), ) .expect("v6 wildcard bind (V6ONLY must be set)"); @@ -1350,7 +1413,6 @@ mod tests { service .configure_credentials("reader".to_string(), "correct-horse".to_string()) - .await .unwrap(); let started = service .start(OpdsStartConfig { @@ -1369,9 +1431,9 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn generating_credentials_while_running_stops_sharing() { - // Generate persists a new verifier immediately, which invalidates the - // password the running listeners accept - so the share must stop. + async fn generating_credentials_while_running_keeps_sharing_live() { + // Generate hot-swaps the live credential snapshot; rotation never + // reads as "sharing turned off". let source = test_source(); let interfaces = Arc::new(FakeInterfaces::new(vec![lan( OpdsInterfaceState::Up, @@ -1387,22 +1449,19 @@ mod tests { service .configure_credentials("reader".to_string(), "correct-horse".to_string()) - .await .unwrap(); service.start(config(8080)).await.unwrap(); assert_eq!(service.status().await.state, OpdsLifecycleState::Running); - let generated = service - .generate_credentials("reader".to_string()) - .await - .unwrap(); + let generated = service.generate_credentials("reader".to_string()).unwrap(); assert!(!generated.password.is_empty()); - assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); - assert!(listeners.active.lock().unwrap().is_empty()); + // The snapshot swapped in place: the share never went down. + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + assert_eq!(listeners.active.lock().unwrap().len(), 1); } #[tokio::test(flavor = "multi_thread")] - async fn credential_change_while_waiting_stops_the_poll() { + async fn credential_change_while_waiting_keeps_the_poll_alive() { let source = test_source(); let interfaces = Arc::new(FakeInterfaces::new(Vec::new())); let listeners = Arc::new(FakeListeners::default()); @@ -1415,7 +1474,6 @@ mod tests { service .configure_credentials("reader".to_string(), "correct-horse".to_string()) - .await .unwrap(); let started = service .start(OpdsStartConfig { @@ -1427,13 +1485,15 @@ mod tests { .unwrap(); assert_eq!(started.state, OpdsLifecycleState::WaitingForInterface); - // Reconfiguring while a Waiting poll holds a stale auth must stop it; - // otherwise the poll would bind with credentials just replaced. + // The poll reads the live snapshot per attempt, so reconfiguring + // mid-wait is safe: it keeps waiting and will bind the latest secret. service .configure_credentials("other".to_string(), "battery-staple".to_string()) - .await .unwrap(); - assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); + assert_eq!( + service.status().await.state, + OpdsLifecycleState::WaitingForInterface + ); assert!(listeners.active.lock().unwrap().is_empty()); } @@ -1451,7 +1511,6 @@ mod tests { service .configure_credentials("reader".to_string(), "correct-horse".to_string()) - .await .unwrap(); let started = service .start(OpdsStartConfig { @@ -1470,6 +1529,96 @@ mod tests { assert!(listeners.active.lock().unwrap().is_empty()); } + #[tokio::test(flavor = "multi_thread")] + async fn rejected_start_leaves_the_live_auth_gate_untouched() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_secs(1), + ); + + service + .configure_credentials("reader".to_string(), "correct-horse".to_string()) + .unwrap(); + service + .start(OpdsStartConfig { + target: OpdsBindTarget::LocalNetworks, + port: 8080, + authentication_enabled: true, + }) + .await + .unwrap(); + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + assert!(service.inner.auth.required()); + + // A stale pane calling start with a conflicting auth flag must fail + // without flipping the gate the running listeners serve. + let conflict = service + .start(OpdsStartConfig { + target: OpdsBindTarget::LocalNetworks, + port: 8081, + authentication_enabled: false, + }) + .await; + assert!(conflict.is_err()); + assert!(service.inner.auth.required()); + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + service.stop().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn reconfigure_with_an_invalid_config_keeps_the_share_running() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_secs(1), + ); + + service.start(config(8080)).await.unwrap(); + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + + // No credentials stored: requiring sign-in must be rejected up front, + // not after the live share has been torn down. + let rejected = service + .reconfigure(OpdsStartConfig { + target: OpdsBindTarget::LocalNetworks, + port: 8080, + authentication_enabled: true, + }) + .await; + assert_eq!(rejected.unwrap_err().code, OpdsErrorCode::AuthRequired); + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + assert!(!listeners.active.lock().unwrap().is_empty()); + service.stop().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn configure_credentials_rejects_a_colon_in_the_username() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with(source, interfaces, listeners, Duration::from_secs(1)); + + let rejected = + service.configure_credentials("me:home".to_string(), "correct-horse".to_string()); + assert!(rejected.is_err()); + assert!(!service.credential_status().configured); + } + #[tokio::test(flavor = "multi_thread")] async fn restarting_after_a_client_connection_releases_the_port() { let probe = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); @@ -1480,7 +1629,7 @@ mod tests { let factory = TcpListenerFactory; let source = missing_source(); let mut first = factory - .start(address, source.clone(), OpdsBasicAuth::disabled()) + .start(address, source.clone(), OpdsBasicAuth::new()) .unwrap(); // A client connects and the SERVER closes first: this port now has a // TIME_WAIT-eligible connection on the server side. @@ -1491,7 +1640,7 @@ mod tests { // Immediate rebind must succeed (SO_REUSEADDR on the socket2 path). let second = factory - .start(address, source, OpdsBasicAuth::disabled()) + .start(address, source, OpdsBasicAuth::new()) .expect("rebind after server-side close must not hit TIME_WAIT"); drop(second); } diff --git a/docs/adr/0003-opds-credentials-are-machine-local.md b/docs/adr/0003-opds-credentials-are-machine-local.md index ad9b971b..a7f79d2e 100644 --- a/docs/adr/0003-opds-credentials-are-machine-local.md +++ b/docs/adr/0003-opds-credentials-are-machine-local.md @@ -1,6 +1,6 @@ # 3. OPDS credentials are machine-local -Status: Accepted - 2026-09-21 +Status: Accepted - 2026-09-21. Storage format amended by ADR 0005: the file holds the reversible password, not a verifier. ## Context diff --git a/docs/adr/0004-two-mode-sharing-state-machine.md b/docs/adr/0004-two-mode-sharing-state-machine.md index 9e943554..9c3f6731 100644 --- a/docs/adr/0004-two-mode-sharing-state-machine.md +++ b/docs/adr/0004-two-mode-sharing-state-machine.md @@ -2,6 +2,8 @@ Status: Accepted - 2026-09-21 +> Amended 2026-09-23 by ADR 0005: the credential is stored reversibly, not as an Argon2 verifier; the live auth snapshot hot-swaps instead of stopping the share. + Supersedes 0002. See "Relationship to 0002" below. ## Context diff --git a/docs/adr/0005-opds-credentials-are-stored-reversibly.md b/docs/adr/0005-opds-credentials-are-stored-reversibly.md new file mode 100644 index 00000000..9c37b66e --- /dev/null +++ b/docs/adr/0005-opds-credentials-are-stored-reversibly.md @@ -0,0 +1,25 @@ +# 5. OPDS credentials are stored reversibly + +Status: Accepted - 2026-09-22 + +## Context + +ADR 0003 stored only an Argon2 verifier, so the password could never be shown again. That forced a one-time-display flow: copy the password into your reader immediately or rotate the credential. Rotating a credential also had to stop a running share, because listeners held a verifier baked in at start. Phil called the rotation-to-recover flow out during design review: "having to rotate to 'reveal' the password suuuuucks." + +The generated password carries roughly 30.5 bits, and failed auth attempts back off exponentially. The server shares a library over the LAN or the user's own networks. + +## Decision + +The credential file stores the password reversibly (a `password` field, not a `passwordVerifier`). The auth layer reads a hot-swappable snapshot of username and password on every request and compares with a constant-time equality check; configuring or generating credentials swaps the snapshot in place, and a running share never stops. Clearing credentials still stops sharing, because the all-networks bind policy exists only while credentials do. + +The Argon2 verifier, its concurrency semaphore, the 503-busy response, the HMAC-tagged result cache, and the response-padding machinery are deleted: all of it existed to make a deliberately slow verifier safe, and none of it has a job once the stored secret is plaintext. The backoff remains the online-guessing defense. + +Pre-reveal credential files fail to parse and are treated as no credentials; users regenerate. + +## Consequences + +Reveal is a read, not a rotation. The reader UI can show and copy the password at any time; "only shown once" disappears from the product. + +A same-user attacker who can read the 0600 credential file gets the plaintext — but that attacker can already read every book in the library, and citadel-server has always held this credential in plaintext TOML, so the stack has accepted reversible storage at rest from the start. Hashing would add cost without moving any real boundary. The one boundary that did change is the webview: `clb_query_opds_credential_secret` hands the plaintext to app JS, so a theoretical XSS now reaches the secret where a verifier used to be the ceiling. The app renders no untrusted HTML today; if that changes, this decision must be revisited alongside a Content-Security-Policy. Users may also type their own (possibly reused) password instead of generating one — the plaintext cost of that choice is theirs; the UI nudges toward generation. ADR 0003's machine-local placement is unchanged: the secret still never travels with a library. + +Libraries and credential files from nightly builds with verifier-format files need one regeneration after updating. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48023ad5..8c65076e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ image = { version = "0.25.6", default-features = false, features = ["jpeg", "png tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } [dev-dependencies] +axum = "0.8.9" tempfile = "3.8" [features] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6b35ff9a..291e37b7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -77,7 +77,9 @@ fn run_tauri_backend() -> std::io::Result<()> { opds::commands::clb_cmd_clear_opds_credentials, opds::commands::clb_cmd_start_opds, opds::commands::clb_cmd_stop_opds, + opds::commands::clb_cmd_reconfigure_opds, opds::commands::clb_query_opds_status, + opds::commands::clb_query_opds_credential_secret, // Window commands menu::clb_cmd_open_settings, ]); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index bc45ffe0..e72a7fcc 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -173,7 +173,7 @@ pub fn open_settings_window(app: &AppHandle) { // are vertically centered on the ~58px tab strip. let builder = WebviewWindowBuilder::new(app, "settings", url) .title("Settings") - .inner_size(680.0, 480.0) + .inner_size(680.0, 520.0) .resizable(false) .minimizable(false) .visible(false) diff --git a/src-tauri/src/opds/commands.rs b/src-tauri/src/opds/commands.rs index 1dc78588..a5ead8a1 100644 --- a/src-tauri/src/opds/commands.rs +++ b/src-tauri/src/opds/commands.rs @@ -1,5 +1,5 @@ use citadel_opds::{ - credential_store::OpdsCredentialStatus, + credential_store::{OpdsCredentialSecret, OpdsCredentialStatus}, password::GeneratedOpdsCredentials, service::{OpdsServiceStatus, OpdsStartConfig, OpdsStatusError}, OpdsService, @@ -13,6 +13,14 @@ pub async fn clb_query_opds_credential_status( Ok(service.credential_status()) } +#[tauri::command] +#[specta::specta] +pub async fn clb_query_opds_credential_secret( + service: tauri::State<'_, OpdsService>, +) -> Result, OpdsStatusError> { + Ok(service.credential_secret()) +} + #[tauri::command] #[specta::specta] pub async fn clb_cmd_configure_opds_credentials( @@ -20,7 +28,7 @@ pub async fn clb_cmd_configure_opds_credentials( username: String, password: String, ) -> Result { - service.configure_credentials(username, password).await + service.configure_credentials(username, password) } #[tauri::command] @@ -29,7 +37,7 @@ pub async fn clb_cmd_generate_opds_credentials( service: tauri::State<'_, OpdsService>, username: String, ) -> Result { - service.generate_credentials(username).await + service.generate_credentials(username) } #[tauri::command] @@ -57,6 +65,15 @@ pub async fn clb_cmd_stop_opds( Ok(service.stop().await) } +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_reconfigure_opds( + service: tauri::State<'_, OpdsService>, + config: OpdsStartConfig, +) -> Result { + service.reconfigure(config).await +} + #[tauri::command] #[specta::specta] pub async fn clb_query_opds_status( diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 87a2301c..19a84219 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -249,9 +249,15 @@ mod tests { let base = format!("http://{}", listener.local_addr().unwrap()); let server_state = state.clone(); let server = tokio::spawn(async move { - axum::serve(listener, citadel_opds::router(Arc::new(server_state))) - .await - .unwrap(); + axum::serve( + listener, + citadel_opds::router( + Arc::new(server_state), + citadel_opds::auth::OpdsBasicAuth::new(), + ), + ) + .await + .unwrap(); }); let client = reqwest::Client::new(); diff --git a/src/bindings.ts b/src/bindings.ts index 53ee4a46..521f49f4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -337,6 +337,14 @@ async clbCmdStopOpds() : Promise> { else return { status: "error", error: e as any }; } }, +async clbCmdReconfigureOpds(config: OpdsStartConfig) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_reconfigure_opds", { config }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async clbQueryOpdsStatus() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_status") }; @@ -345,6 +353,14 @@ async clbQueryOpdsStatus() : Promise> else return { status: "error", error: e as any }; } }, +async clbQueryOpdsCredentialSecret() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_credential_secret") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async clbCmdOpenSettings() : Promise { await TAURI_INVOKE("clb_cmd_open_settings"); } @@ -458,6 +474,11 @@ export type DetectionSource = * Calibre's default `~/Calibre Library` folder. */ "default-folder" +/** + * Returned when credentials are generated; the password is also stored + * reversibly (ADR 0005) so the UI can reveal it again later. + */ +export type GeneratedOpdsCredentials = { username: string; password: string } /** * Book identifiers, such as ISBN, DOI, Google Books ID, etc. */ @@ -551,13 +572,22 @@ export type NewAuthor = { name: string; sortable_name: string | null } * credentials (see the auth integration) since it serves every network the * computer can reach. */ -export type OpdsBindTarget = { type: "localNetworks" } | { type: "allInterfaces" } +export type OpdsBindTarget = "localNetworks" | "allInterfaces" +/** + * The stored secret, for the reader UI's reveal flow. The password is kept + * reversibly on purpose (ADR 0005); the app process may show it to its user. + */ +export type OpdsCredentialSecret = { username: string; password: string } +export type OpdsCredentialStatus = { configured: boolean; username: string | null } export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "authRequired" | "configurationConflict" | "interfaceUnavailable" | "portUnavailable" | "listenerFailed" | "unexpected" export type OpdsLifecycleState = "stopped" | "starting" | "running" | "waitingForInterface" | "error" -export type OpdsServiceStatus = { state: OpdsLifecycleState; activeLibraryId: string | null; urls: string[]; error: OpdsStatusError | null } +export type OpdsServiceStatus = { state: OpdsLifecycleState; activeLibraryId: string | null; urls: string[]; error: OpdsStatusError | null; +/** + * The configuration the server is actually running with, so the UI can + * show the live port and scope instead of a possibly-stale draft. + */ +config: OpdsStartConfig | null } export type OpdsStartConfig = { target: OpdsBindTarget; port: number; authenticationEnabled: boolean } -export type GeneratedOpdsCredentials = { username: string; password: string } -export type OpdsCredentialStatus = { configured: boolean; username: string | null } export type OpdsStatusError = { code: OpdsErrorCode; message: string } export type ProviderStatus = { provider: MetadataProvider; is_valid: boolean; message: string } export type RemoteFile = { url: string } From 31148c3202c87651b05973c7a8cc0dc28d99e826 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Tue, 22 Sep 2026 22:58:21 -0700 Subject: [PATCH 2/3] style: drop stale pointer-cursor wording from the interactivity block The pointer-cursor overrides are gone (arrow cursor on every control), but this header still said the block signals clickability with pointer cursors. Co-Authored-By: Claude Opus 5.5 --- src/styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/styles.css b/src/styles.css index fca4e136..827e8f5b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -333,8 +333,9 @@ a:any-link { /* ========================================================================== * Interactivity polish (additive block; keep at the end of this file). - * Everything below signals what is clickable: pointer cursors, hover - * backgrounds, and larger touch targets. Token blocks above are off limits. + * Hover backgrounds and larger touch targets; cursors are governed by the + * AppKit-style policy at the top of this file. Token blocks above are off + * limits. * ========================================================================== */ /* From fcf5b84a1d50d759122c46d0739e1e24ae54f194 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Tue, 22 Sep 2026 22:58:21 -0700 Subject: [PATCH 3/3] feat(settings): OPDS sharing pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Sharing tab in Settings for the local OPDS server. Rows layout: share toggle with the live listening URL (copyable), reachable-from scope, port, and reader sign-in with username and a reversible password field (reveal toggle, press-and-hold regenerate with a gold letter wave that flares HDR-bright on capable displays). The pane renders backend status, never local guesses. useOpdsSharing polls the service and serialises mutations; start and reconfigure treat a failed bind as failure. A port change that fails to bind rolls back to the previous port and restarts there. Errors render beside the control that caused them — server errors inline in the Port row, credential errors under the password — and a failed start clears when the port is edited or the pane is left. Sharing settings (scope, port, sign-in, username) persist through the settings store with a migration from the previous shape. Co-Authored-By: Claude Opus 5.5 --- src/components/icons/F7Eye.tsx | 19 + src/components/icons/F7EyeSlash.tsx | 19 + src/components/icons/F7Wifi.tsx | 19 + src/components/icons/TablerCopy.tsx | 2 +- src/components/icons/TablerRefresh.tsx | 25 + src/components/molecules/BookGrid.tsx | 2 +- .../molecules/LibraryListCard.stories.tsx | 1 + src/components/organisms/SettingsPanes.tsx | 14 +- .../organisms/SharingSettings.module.css | 604 +++++++++++++++ src/components/organisms/SharingSettings.tsx | 718 ++++++++++++++++++ src/components/ui/SegmentedControl.module.css | 7 +- src/components/ui/SegmentedControl.tsx | 2 + src/lib/hooks/use-opds-sharing.test.ts | 47 ++ src/lib/hooks/use-opds-sharing.ts | 287 +++++++ src/lib/platform/create.ts | 2 + src/lib/platform/settings/migrate.test.ts | 51 +- src/lib/platform/settings/migrate.ts | 45 +- src/lib/platform/settings/types.ts | 18 + src/lib/platform/types.ts | 2 + src/lib/services/opds.ts | 61 ++ src/stores/settings/store.ts | 6 + 21 files changed, 1945 insertions(+), 6 deletions(-) create mode 100644 src/components/icons/F7Eye.tsx create mode 100644 src/components/icons/F7EyeSlash.tsx create mode 100644 src/components/icons/F7Wifi.tsx create mode 100644 src/components/icons/TablerRefresh.tsx create mode 100644 src/components/organisms/SharingSettings.module.css create mode 100644 src/components/organisms/SharingSettings.tsx create mode 100644 src/lib/hooks/use-opds-sharing.test.ts create mode 100644 src/lib/hooks/use-opds-sharing.ts create mode 100644 src/lib/services/opds.ts diff --git a/src/components/icons/F7Eye.tsx b/src/components/icons/F7Eye.tsx new file mode 100644 index 00000000..0126316d --- /dev/null +++ b/src/components/icons/F7Eye.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +export function F7Eye(props: SVGProps) { + return ( + + ); +} diff --git a/src/components/icons/F7EyeSlash.tsx b/src/components/icons/F7EyeSlash.tsx new file mode 100644 index 00000000..a7645c27 --- /dev/null +++ b/src/components/icons/F7EyeSlash.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +export function F7EyeSlash(props: SVGProps) { + return ( + + ); +} diff --git a/src/components/icons/F7Wifi.tsx b/src/components/icons/F7Wifi.tsx new file mode 100644 index 00000000..014f2b2c --- /dev/null +++ b/src/components/icons/F7Wifi.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +export function F7Wifi(props: SVGProps) { + return ( + + ); +} diff --git a/src/components/icons/TablerCopy.tsx b/src/components/icons/TablerCopy.tsx index 37e7fead..fbfa90f2 100644 --- a/src/components/icons/TablerCopy.tsx +++ b/src/components/icons/TablerCopy.tsx @@ -6,10 +6,10 @@ export function TablerCopy(props: SVGProps) { xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" + aria-hidden="true" viewBox="0 0 24 24" {...props} > - Copy ) { + return ( + + ); +} diff --git a/src/components/molecules/BookGrid.tsx b/src/components/molecules/BookGrid.tsx index 72f8b1b2..de0cc97d 100644 --- a/src/components/molecules/BookGrid.tsx +++ b/src/components/molecules/BookGrid.tsx @@ -13,13 +13,13 @@ import { import type { BookView } from "@/BookView"; import type { LibraryBook } from "@/bindings"; import { LoadingOverlay } from "@/components/ui"; -import { useCoverThumbsMap } from "@/stores/library/store"; import { computeColumnCount, computeRowCount, rowOfIndex, rowSlice, } from "@/lib/grid-virtual"; +import { useCoverThumbsMap } from "@/stores/library/store"; import { BookCard } from "../atoms/BookCard"; import cardClasses from "../atoms/BookCard.module.css"; diff --git a/src/components/molecules/LibraryListCard.stories.tsx b/src/components/molecules/LibraryListCard.stories.tsx index 68ecbfc8..13c95b1c 100644 --- a/src/components/molecules/LibraryListCard.stories.tsx +++ b/src/components/molecules/LibraryListCard.stories.tsx @@ -12,6 +12,7 @@ const MOCK_PLATFORM: PlatformAdapter = { canCopyToClipboard: true, canOpenLocalPaths: true, supportsAutoUpdates: true, + supportsLocalOpdsServer: true, }, dialogs: { openFile: async () => null, diff --git a/src/components/organisms/SettingsPanes.tsx b/src/components/organisms/SettingsPanes.tsx index 517ddb94..d9e4b8d7 100644 --- a/src/components/organisms/SettingsPanes.tsx +++ b/src/components/organisms/SettingsPanes.tsx @@ -3,12 +3,14 @@ import { useCallback, useId, useRef, useState } from "react"; import { commands } from "@/bindings"; import { F7BookFill } from "@/components/icons/F7BookFill"; import { F7Gear } from "@/components/icons/F7Gear"; +import { F7Wifi } from "@/components/icons/F7Wifi"; import { FluentLibraryFilled } from "@/components/icons/FluentLibraryFilled"; import { CurrentLibraryCard } from "@/components/molecules/CurrentLibraryCard"; import { LibraryListCard } from "@/components/molecules/LibraryListCard"; import type { AddLibraryResult } from "@/components/molecules/SwitchLibraryForm"; import { SwitchLibraryForm } from "@/components/molecules/SwitchLibraryForm"; import classes from "@/components/organisms/SettingsPanes.module.css"; +import { SharingSettings } from "@/components/organisms/SharingSettings"; import { Button, SegmentedControl, Switch, TextInput } from "@/components/ui"; import { useAppUpdates } from "@/lib/hooks/use-app-updates"; import { useLibrarySelection } from "@/lib/hooks/use-library-selection"; @@ -20,7 +22,12 @@ import { applyColorScheme } from "@/lib/theme-manager"; import { useAnySourceEnabled } from "@/stores/settings/metadata-providers"; import { useSettings } from "@/stores/settings/store"; -const SETTINGS_TABS = ["general", "library", "integrations"] as const; +const SETTINGS_TABS = [ + "general", + "library", + "sharing", + "integrations", +] as const; type SettingsTab = (typeof SETTINGS_TABS)[number]; const TAB_META: Record< @@ -35,6 +42,10 @@ const TAB_META: Record< label: "Library", icon: (className) => , }, + sharing: { + label: "Sharing", + icon: (className) => , + }, integrations: { label: "Metadata", icon: (className) => , @@ -146,6 +157,7 @@ export const SettingsPanes = ({ onRequestClose }: SettingsPanesProps) => {
{activeTab === "general" && } {activeTab === "library" && } + {activeTab === "sharing" && } {activeTab === "integrations" && }
diff --git a/src/components/organisms/SharingSettings.module.css b/src/components/organisms/SharingSettings.module.css new file mode 100644 index 00000000..3522408c --- /dev/null +++ b/src/components/organisms/SharingSettings.module.css @@ -0,0 +1,604 @@ +/* Sharing pane (production). Rows layout signed off by Phil; cursors follow + * the AppKit-style policy in styles.css — no pointer hands anywhere. + * + * Motion values (Phil's dial-in): hold 700ms with an 80ms lead before the + * fill starts (620ms fill), fill = gold disc 1.5× the button spilling into + * the shell, 260ms fade on fire, 360° turn overshooting to 384° in 620ms, + * gold letter wave 500ms/letter at 42ms stagger, Copied 1.5s. The JS + * constants in SharingSettings.tsx mirror these. */ + +.stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.intro { + margin-bottom: 0.25rem; +} + +.sectionTitle { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + line-height: 1.3; + color: var(--ctd-ink); +} + +.note { + max-width: 34rem; + margin: 0.375rem 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--ctd-ink-soft); +} + +.unsupported { + padding: 0.25rem 0.125rem; +} + +.unsupported h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.unsupported p { + max-width: 65ch; + margin: 0.25rem 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ctd-ink-soft); +} + +.card { + display: flex; + flex-direction: column; + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + background: var(--ctd-surface); +} + +:global([data-theme="dark"]) .card { + background: var(--ctd-surface-strong); +} + +.card > * + * { + border-top: 1px solid var(--ctd-border); +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2.75rem; + padding: 0.5rem 0.875rem; +} + +/* Rows whose control side carries extra text (errors, lock notes): the label + * stays pinned to the field, not centered against the whole column. */ +.rowTop { + align-items: flex-start; +} + +.rowTop > label, +.rowTop > .rowLabelText { + /* Match the control's line box so flex-start aligns label to field. */ + line-height: 1.625rem; +} + +.row > label, +.row > .rowLabelText, +.row label { + font-size: 0.8125rem; + color: var(--ctd-ink); +} + +.rowNote { + margin: 0.125rem 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--ctd-ink-soft); +} + +.controlStack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.fieldNote, +.fieldError { + margin: 0; + font-size: 0.6875rem; + line-height: 1.3; + text-align: right; +} + +.fieldNote { + color: var(--ctd-ink-soft); +} + +.fieldError { + color: var(--pal-red); +} + +/* ------------------------------------------------------------------------ + * Listening strip: darker than the card, left-aligned under Share library. + * The URL itself is the button (hover bg); the copy icon sits inside at the + * trailing edge and appears on hover/focus; Copy/Copied sits outside. + * ---------------------------------------------------------------------- */ +.rowListen { + display: flex; + padding: 0.4rem 0.875rem; + background: var(--pal-mantle); +} + +.listen { + display: flex; + align-items: center; + gap: 0.45rem; + margin: 0; + font-size: 0.8125rem; + color: var(--ctd-ink-soft); +} + +.listen code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + color: var(--ctd-ink); +} + +.urlChip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.1rem 0.4rem 0.1rem 0.5rem; + border: none; + background: transparent; + border-radius: 0.375rem; + font: inherit; + color: var(--ctd-ink); +} + +.urlChip:hover, +.urlChip.urlCopied { + background: var(--ctd-nav-hover-bg); +} + +.urlChip:focus-visible { + outline: none; + box-shadow: var(--ctd-focus-shadow); +} + +/* Fixed 0.75rem slot: reserved even when hidden, so hover/copied never + * shifts the URL or collapses the button. */ +.urlChipIcon { + display: inline-grid; + place-items: center; + flex: none; + width: 0.75rem; + height: 0.75rem; + font-size: 0.75rem; + color: var(--ctd-ink-soft); + opacity: 0; + transition: opacity 120ms ease-out; +} + +.urlChipIcon svg { + width: 1em; + height: 1em; + display: block; +} + +.urlChip:hover .urlChipIcon, +.urlChip:focus-visible .urlChipIcon, +.urlChip.urlCopied .urlChipIcon { + opacity: 1; +} + +.urlChip.urlCopied .urlChipIcon { + color: var(--ctd-ink); +} + +/* Check pops into the slot the copy glyph just left (5B). */ +.urlCheck { + animation: check-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1.2) both; +} + +@keyframes check-in { + from { + opacity: 0; + transform: scale(0.6); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Copy/Copied share one grid cell sized to the wider word, so swapping never + * moves anything. Revealed while the URL button is hovered/focused and held + * while "Copied" flashes. */ +.urlCopyLabel { + display: inline-grid; + font-size: 0.6875rem; + font-weight: 500; + line-height: 1; + color: var(--ctd-ink-soft); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.urlCopyLabel > span { + grid-area: 1 / 1; + visibility: hidden; +} + +.urlCopyLabel:not([data-copied="true"]) > span:first-child, +.urlCopyLabel[data-copied="true"] > span:last-child { + visibility: visible; +} + +.urlChip:hover + .urlCopyLabel, +.urlChip:focus-visible + .urlCopyLabel, +.urlCopyLabel[data-copied="true"] { + opacity: 1; +} + +.urlCopyLabel[data-copied="true"] { + color: var(--ctd-ink); +} + +/* ------------------------------------------------------------------------ + * Fields + * ---------------------------------------------------------------------- */ +.portInput { + width: 6rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--ctd-border-strong); + border-radius: var(--ctd-radius-control); + background: var(--ctd-control-bg-strong); + box-shadow: var(--ctd-field-inner-shadow); + font: inherit; + font-size: 0.8125rem; + color: var(--ctd-control-text); + text-align: right; +} + +.fieldInput { + width: 13.5rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--ctd-border-strong); + border-radius: var(--ctd-radius-control); + background: var(--ctd-control-bg-strong); + box-shadow: var(--ctd-field-inner-shadow); + font: inherit; + font-size: 0.8125rem; + color: var(--ctd-control-text); +} + +.portInput:focus, +.fieldInput:focus { + outline: none; + box-shadow: var(--ctd-focus-shadow); +} + +/* Red border only; the text stays ink. Focused, the ring turns red too so + * the border and ring read as one error state instead of grey-on-red. */ +.inputError { + border-color: var(--ctd-danger-border); +} + +.inputError:focus { + box-shadow: 0 0 0 3px oklch(from var(--pal-red) l c h / 0.3); +} + +/* Error text in the gap between a row's label and its control. */ +.inlineError { + flex: 1; + min-width: 0; + margin: 0; + font-size: 0.6875rem; + line-height: 1.3; + text-align: right; + color: var(--pal-red); +} + +/* Wrapper that gives the early-release pill an unclipped mount. */ +.shellWrap { + position: relative; + display: inline-flex; +} + +/* The password field shell: input + eye + regenerate as one control. The + * shell clips (overflow hidden) — the gold disc spilling out of the hold + * button is meant to stop at the shell's edge. */ +.fieldShell { + display: flex; + align-items: center; + width: 13.5rem; + padding-right: 0.25rem; + border: 1px solid var(--ctd-border-strong); + border-radius: var(--ctd-radius-control); + background: var(--ctd-control-bg-strong); + box-shadow: var(--ctd-field-inner-shadow); + position: relative; + overflow: hidden; +} + +.fieldShell:focus-within { + box-shadow: var(--ctd-focus-shadow); +} + +.fieldBare { + flex: 1; + min-width: 0; + border: none; + background: transparent; + box-shadow: none; +} + +.fieldBare:focus { + box-shadow: none; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.02em; +} + +/* Gold letter wave when the password rotates: the real input text goes + * transparent and a per-glyph overlay plays the wave in place. The overlay + * mirrors the input's metrics exactly (mono, 0.8125rem, 0.6rem left pad, + * 0.02em tracking) so glyphs land on top of their own pixels. */ +.washing .washTarget { + color: transparent; + caret-color: transparent; +} + +.washOverlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 3.5rem; + display: flex; + align-items: center; + padding-left: 0.6rem; + overflow: hidden; + white-space: pre; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + letter-spacing: 0.02em; + color: var(--ctd-control-text); + pointer-events: none; + z-index: 1; +} + +/* Each glyph: 500ms, gold for the middle 50% of its own timeline; JS sets the + * per-letter delay (index × 42ms). 13 glyphs ≈ 1000ms end to end. The 50% + * brightness > 1 renders above SDR white on HDR displays (WebKit). */ +.washOverlay > span { + animation: letter-wave 500ms ease-in-out both; +} + +@keyframes letter-wave { + 0% { + color: var(--ctd-control-text); + filter: brightness(1); + } + 25%, + 75% { + color: oklch(75% 0.14 90); + filter: brightness(1); + } + 50% { + color: oklch(75% 0.14 90); + filter: brightness(1.8); + } + 100% { + color: var(--ctd-control-text); + filter: brightness(1); + } +} + +.inlineIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + padding: 0; + border: none; + background: transparent; + border-radius: var(--ctd-radius-control); + font-size: 0.9375rem; + color: var(--ctd-ink-soft); +} + +.inlineIcon:hover:not(:disabled) { + color: var(--ctd-ink); +} + +.inlineIcon:focus-visible { + outline: none; + box-shadow: var(--ctd-focus-shadow); +} + +.inlineIcon:disabled { + opacity: 0.45; +} + +.inlineIcon svg, +.urlChip svg, +.holdButton svg { + pointer-events: none; +} + +/* ------------------------------------------------------------------------ + * Press-and-hold regenerate. Circle button; a gold disc 1.5× the button + * grows from its centre (spilling into the shell) over 620ms after an 80ms + * lead, so it completes exactly at the 700ms threshold. On fire the disc + * holds full and fades 260ms while the icon does one overshooting turn. + * Releasing early cancels the fill and shows the pill. + * ---------------------------------------------------------------------- */ +.holdButton { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + padding: 0; + border: none; + background: transparent; + border-radius: 50%; + overflow: visible; + font-size: 0.9375rem; + color: var(--ctd-ink-soft); + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.holdButton:hover:not(:disabled) { + color: var(--ctd-ink); +} + +.holdButton:focus-visible { + outline: none; + box-shadow: var(--ctd-focus-shadow); +} + +.holdButton:disabled { + opacity: 0.45; +} + +.holdIcon { + position: relative; + z-index: 1; + display: inline-flex; + transform-origin: 50% 50%; + transition: color 140ms ease-out; +} + +.holdIcon svg { + display: block; + width: 1em; + height: 1em; +} + +/* Icon goes to ink over the gold as the disc covers it. */ +.holdButton.holding .holdIcon { + color: var(--ctd-ink); + transition: color 220ms ease-out 80ms; +} + +.holdButton.fired .holdIcon { + color: var(--ctd-ink); + animation: hold-spin 620ms cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +/* 1.5 × 1.625rem = 2.4375rem. */ +.holdFill { + position: absolute; + left: 50%; + top: 50%; + width: 2.4375rem; + height: 2.4375rem; + border-radius: 50%; + background: oklch(85% 0.1 90); + transform: translate(-50%, -50%) scale(0); + pointer-events: none; +} + +.holdButton.holding .holdFill { + animation: hold-fill 620ms linear 80ms forwards; +} + +.holdButton.fired .holdFill { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + transition: opacity 260ms ease-out; +} + +@keyframes hold-fill { + from { + transform: translate(-50%, -50%) scale(0); + } + to { + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes hold-spin { + 0% { + transform: rotate(0deg); + } + 62% { + transform: rotate(384deg); + animation-timing-function: ease-in-out; + } + 100% { + transform: rotate(360deg); + } +} + +/* Early-release pill: anchored under the hold button, outside the shell. */ +.holdHint { + position: absolute; + right: 0; + top: calc(100% + 0.3rem); + white-space: nowrap; + padding: 0.15rem 0.5rem; + border-radius: 0.375rem; + background: var(--ctd-ink); + color: var(--pal-base); + font-size: 0.6875rem; + line-height: 1.4; + pointer-events: none; + z-index: 10; + animation: hint-in 120ms ease-out both; +} + +@keyframes hint-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: none; + } +} + +/* Reduce Motion: keep the states (fill appears full at the 80ms lead, check + * and pill just appear) but drop the motion and the letter wave. */ +@media (prefers-reduced-motion: reduce) { + .holdFill, + .holdIcon, + .urlCheck, + .holdHint, + .urlChipIcon, + .urlCopyLabel { + /* biome-ignore lint/complexity/noImportantStyles: reduced-motion must beat the animation declarations above */ + animation-duration: 1ms !important; + /* biome-ignore lint/complexity/noImportantStyles: see above */ + transition-duration: 1ms !important; + } + + .washOverlay { + display: none; + } + + .washing .washTarget { + color: var(--ctd-control-text); + caret-color: auto; + } +} diff --git a/src/components/organisms/SharingSettings.tsx b/src/components/organisms/SharingSettings.tsx new file mode 100644 index 00000000..9e8dd3f1 --- /dev/null +++ b/src/components/organisms/SharingSettings.tsx @@ -0,0 +1,718 @@ +//! The sharing pane: server toggle, scope, port, reader sign-in. +//! +//! Design contract (ADR 0005 + the rows layout signed off by Phil): +//! - The backend owns server truth; the pane renders status and config from +//! the service, never local guesses. +//! - Credentials are stored reversibly; the password field holds the real +//! secret masked by default, with an eye toggle and a press-and-hold +//! regenerate. +//! - Scope and port changes reconfigure a running server; sign-in off just +//! means "not required" and never deletes the stored secret. + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import type { OpdsLifecycleState } from "@/bindings"; +import { F7Eye } from "@/components/icons/F7Eye"; +import { F7EyeSlash } from "@/components/icons/F7EyeSlash"; +import { TablerCopy } from "@/components/icons/TablerCopy"; +import { TablerRefresh } from "@/components/icons/TablerRefresh"; +import classes from "@/components/organisms/SharingSettings.module.css"; +import { SegmentedControl, Switch } from "@/components/ui"; +import type { SharingError } from "@/lib/hooks/use-opds-sharing"; +import { + SHARING_ERROR_SOURCE, + useOpdsSharing, +} from "@/lib/hooks/use-opds-sharing"; +import { usePlatform } from "@/lib/platform/context"; +import type { SharingSettings as SharingPreferences } from "@/lib/platform/settings/types"; +import { useSettings } from "@/stores/settings/store"; + +/** Motion values tuned by Phil on the dial-in page. Durations here must + * match the keyframes in the module CSS. */ +/** How long the regenerate button must be held before it fires. */ +const HOLD_THRESHOLD_MS = 700; +/** 360° turn with an overshoot settle; also how long the fill fade state lives. */ +const SPIN_MS = 620; +/** Early-release pill lifetime. */ +const HINT_MS = 1_200; +/** Per-letter gold band (50% of the 1000ms wave) and letter stagger. */ +const WASH_LETTER_MS = 500; +const WASH_STAGGER_MS = 42; +/** Copied state lifetime. */ +const COPIED_MS = 1_500; +/** Shown in place of the URL while the service is on but not yet serving. */ +const PENDING_LABEL = { + starting: "Starting…", + waitingForInterface: "Waiting for a network connection…", +} as const satisfies Partial>; +/** The port row holds errors inline beside the field, so they stay short. */ +const PORT_RANGE_MESSAGE = "Choose a port from 1 to 65535."; +const PORT_UNAVAILABLE_MESSAGE = "Port not available, please choose another"; +const serverErrorMessage = (error: SharingError): string => + error.code === "portUnavailable" ? PORT_UNAVAILABLE_MESSAGE : error.message; +/** HTTP Basic splits `user:pass` at the first colon; the backend rejects it too. */ +const USERNAME_FORBIDDEN = ":"; + +export const SharingSettings = () => { + const platform = usePlatform(); + const supported = platform.capabilities.supportsLocalOpdsServer; + const sharing = useSettings((state) => state.sharing); + const setSharing = useSettings((state) => state.setSharing); + const controller = useOpdsSharing(supported); + + if (!supported) { + return ( +
+

Sharing requires the desktop app

+

+ The web version cannot run a local server. Open Citadel on macOS or + another supported desktop to share this library. +

+
+ ); + } + + return ( + + ); +}; + +interface SharingPaneProps { + sharing: SharingPreferences; + setSharing: (next: SharingPreferences) => Promise; + controller: ReturnType; +} + +const SharingPane = ({ controller, sharing, setSharing }: SharingPaneProps) => { + const platform = usePlatform(); + const shareSwitchId = useId(); + const [portInput, setPortInput] = useState(String(sharing.port)); + /** Why the last port change was rolled back; outlives the restart that + * clears the service error. */ + const [portError, setPortError] = useState(null); + const [usernameInput, setUsernameInput] = useState("citadel"); + const [usernameDirty, setUsernameDirty] = useState(false); + const [password, setPassword] = useState(""); + const [passwordRevealed, setPasswordRevealed] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const [holding, setHolding] = useState(false); + const [holdHint, setHoldHint] = useState(false); + const [fired, setFired] = useState(false); + // Bumped on every fire so a re-fire during a running spin/wave restarts + // the animation (React remounts the keyed element). + const [fireSeq, setFireSeq] = useState(0); + const [passwordWash, setPasswordWash] = useState(false); + const passwordRef = useRef(null); + const committedRef = useRef(""); + const portFocusedRef = useRef(false); + const reconfiguringRef = useRef(false); + const urlCopiedTimer = useRef(null); + const holdTimer = useRef(null); + const holdActive = useRef(false); + const hintTimer = useRef(null); + const firedTimer = useRef(null); + const washTimer = useRef(null); + + const status = controller.status; + const shareOn = + status?.state === "running" || + status?.state === "starting" || + status?.state === "waitingForInterface"; + const credentialsConfigured = controller.credentials.configured; + const allNetworksUnlocked = + sharing.authenticationEnabled && credentialsConfigured; + const sharingNeedsPassword = + sharing.authenticationEnabled && !credentialsConfigured; + const usernameInvalid = usernameInput.includes(USERNAME_FORBIDDEN); + const port = Number(portInput); + const portValid = Number.isInteger(port) && port > 0 && port <= 65_535; + + const failedRef = useRef(false); + useEffect(() => { + failedRef.current = status?.state === "error"; + }, [status?.state]); + const { stop } = controller; + useEffect( + () => () => { + // Leaving the pane acknowledges a failed start: reset the service + // to stopped so the stale error doesn't greet the next visit. + if (failedRef.current) void stop(); + }, + [stop], + ); + + const persist = useCallback( + (patch: Partial) => { + const current = useSettings.getState().sharing; + return setSharing({ ...current, ...patch }); + }, + [setSharing], + ); + + // While sharing, the server's actual configuration is authoritative — + // except while the user is editing the port field or a commit is still + // applying (status lags one poll behind the settings during a restart). + useEffect(() => { + const livePort = status?.config?.port; + if (!livePort || portFocusedRef.current || reconfiguringRef.current) return; + if (livePort !== sharing.port) { + setPortInput(String(livePort)); + void persist({ port: livePort }); + } + }, [status?.config?.port, sharing.port, persist]); + + useEffect(() => setPortInput(String(sharing.port)), [sharing.port]); + + useEffect( + () => () => { + for (const timer of [ + urlCopiedTimer, + holdTimer, + hintTimer, + firedTimer, + washTimer, + ]) { + if (timer.current !== null) window.clearTimeout(timer.current); + } + }, + [], + ); + + // The backend owns the username (single source of truth); the field + // mirrors it until the user edits, and re-syncs if another window rotates. + useEffect(() => { + const stored = controller.credentials.username; + if (!usernameDirty && stored) setUsernameInput(stored); + }, [controller.credentials.username, usernameDirty]); + + // The stored secret is readable by design (ADR 0005): the field holds the + // real password, masked by default and revealed with the eye toggle. + // biome-ignore lint/correctness/useExhaustiveDependencies: credentialSecret is a stable callback; the controller object itself is rebuilt every render and must not be a dependency. + useEffect(() => { + if (!credentialsConfigured) return; + let alive = true; + void controller.credentialSecret().then((secret) => { + if (!alive || !secret) return; + setPassword(secret.password); + committedRef.current = secret.password; + }); + return () => { + alive = false; + }; + }, [ + credentialsConfigured, + controller.credentials.username, + controller.credentialSecret, + ]); + + const toggleShare = async (on: boolean) => { + setPortError(null); + if (on) { + await controller.start({ + target: sharing.target, + port: portValid ? port : sharing.port, + authenticationEnabled: sharing.authenticationEnabled, + }); + return; + } + await controller.stop(); + }; + + const changeTarget = (target: "localNetworks" | "allInterfaces") => { + setPortError(null); + void persist({ target }); + if (shareOn) { + void controller.reconfigure({ + target, + port: portValid ? port : sharing.port, + authenticationEnabled: sharing.authenticationEnabled, + }); + } + }; + + const commitPort = async (event: React.FocusEvent) => { + // The DOM value is the ground truth at blur time; state and refs can + // lag if the last input event has not flushed through React yet. + const latest = Number(event.currentTarget.value); + if (!Number.isInteger(latest) || latest <= 0 || latest > 65_535) return; + if (latest === sharing.port) return; + const previous = sharing.port; + setPortError(null); + void persist({ port: latest }); + if (!shareOn) return; + const scope = { + target: sharing.target, + authenticationEnabled: sharing.authenticationEnabled, + }; + reconfiguringRef.current = true; + try { + const applied = await controller.reconfigure({ ...scope, port: latest }); + if (applied.ok) return; + // A failed restart must not leave settings pointing at a port the + // server never took, nor leave sharing down: go back to the port + // that worked and keep the reason next to the field. + setPortError(applied.error); + void persist({ port: previous }); + setPortInput(String(previous)); + await controller.reconfigure({ ...scope, port: previous }); + } finally { + reconfiguringRef.current = false; + } + }; + + const toggleSignIn = async (on: boolean) => { + setPortError(null); + // Off is just "not required": the stored secret stays, so turning + // sign-in back on restores the same password. + let target = sharing.target; + if (!on && target === "allInterfaces") { + // All-networks sharing cannot exist without sign-in. + target = "localNetworks"; + } + await persist({ authenticationEnabled: on, target }); + setPasswordRevealed(false); + if (shareOn) { + await controller.reconfigure({ + target, + port: portValid ? port : sharing.port, + authenticationEnabled: on, + }); + } + }; + + const settlePassword = (event: React.FocusEvent) => { + // The DOM value is the ground truth at blur time. + const value = event.currentTarget.value; + if (value === committedRef.current) return; + if (!value.trim()) { + revertPassword(); + return; + } + void persist({ username: usernameInput.trim() }); + void controller + .configureCredentials({ username: usernameInput.trim(), password: value }) + .then((ok) => { + // On failure the field snaps back to the secret the server + // actually holds instead of lying about the commit. + if (ok) { + committedRef.current = value; + } else { + revertPassword(); + } + }); + }; + + const revertPassword = () => { + setPassword(committedRef.current); + }; + + const settleUsername = () => { + const username = usernameInput.trim(); + if (usernameInvalid) return; + if (!username || username === controller.credentials.username) { + setUsernameDirty(false); + return; + } + // Nothing stored yet: the first generate or password commit carries it. + if (!committedRef.current) return; + setUsernameDirty(false); + void controller + .configureCredentials({ username, password: committedRef.current }) + .then((ok) => { + if (!ok) setUsernameInput(controller.credentials.username ?? username); + }); + }; + + const copyUrl = async (url: string) => { + await platform.clipboard.writeText(url); + setUrlCopied(true); + if (urlCopiedTimer.current !== null) + window.clearTimeout(urlCopiedTimer.current); + urlCopiedTimer.current = window.setTimeout(() => { + urlCopiedTimer.current = null; + setUrlCopied(false); + }, COPIED_MS); + }; + + const fireRegenerate = async () => { + const username = usernameInput.trim(); + if (!username || usernameInvalid) return; + // Fire beat: fill holds at full size and fades while the icon turns. + setFireSeq((seq) => seq + 1); + setFired(true); + if (firedTimer.current !== null) window.clearTimeout(firedTimer.current); + firedTimer.current = window.setTimeout(() => { + firedTimer.current = null; + setFired(false); + }, SPIN_MS); + const generated = await controller.generateCredentials(username); + if (!generated) return; + setPassword(generated); + setPasswordRevealed(true); + committedRef.current = generated; + // The wave runs over the new, revealed glyphs only — never over the + // masked field, so the overlay cannot leak a hidden password. + setPasswordWash(true); + if (washTimer.current !== null) window.clearTimeout(washTimer.current); + washTimer.current = window.setTimeout( + () => { + washTimer.current = null; + setPasswordWash(false); + }, + WASH_LETTER_MS + WASH_STAGGER_MS * Math.max(0, generated.length - 1) + 60, + ); + }; + + const showHoldHint = () => { + setHoldHint(true); + if (hintTimer.current !== null) window.clearTimeout(hintTimer.current); + hintTimer.current = window.setTimeout(() => { + hintTimer.current = null; + setHoldHint(false); + }, HINT_MS); + }; + + // Press-and-hold regenerate: an accidental click never rotates the + // password. A release before the threshold explains itself with the pill; + // leaving the button or a cancelled pointer aborts silently. + const beginHold = (event: React.PointerEvent) => { + if (holdActive.current || event.button !== 0) return; + holdActive.current = true; + setHolding(true); + holdTimer.current = window.setTimeout(() => { + holdTimer.current = null; + holdActive.current = false; + setHolding(false); + void fireRegenerate(); + }, HOLD_THRESHOLD_MS); + }; + + const endHold = (early: boolean) => { + if (!holdActive.current) return; + holdActive.current = false; + if (holdTimer.current !== null) { + window.clearTimeout(holdTimer.current); + holdTimer.current = null; + } + setHolding(false); + if (early) showHoldHint(); + }; + + const portInvalid = !portValid; + const serverError = + portError ?? + (controller.error?.source === SHARING_ERROR_SOURCE.server + ? controller.error + : null); + const credentialError = + controller.error?.source === SHARING_ERROR_SOURCE.credentials + ? controller.error + : null; + const portMessage = portInvalid + ? PORT_RANGE_MESSAGE + : serverError && serverErrorMessage(serverError); + const listenUrl = status?.state === "running" ? status.urls[0] : undefined; + const pendingLabel = + status?.state === "starting" || status?.state === "waitingForInterface" + ? PENDING_LABEL[status.state] + : null; + + if (!status) { + return ( +
+

Checking sharing status…

+ {controller.error && ( +

+ {controller.error.message} +

+ )} +
+ ); + } + + return ( +
+
+

Sharing

+

+ Let OPDS reader apps browse and download from Citadel. +

+
+ +
+
+ +
+ void toggleShare(on)} + /> + {sharingNeedsPassword && ( +

+ Set a password below to enable sharing. +

+ )} +
+
+ {pendingLabel && ( +
+

{pendingLabel}

+
+ )} + {listenUrl && ( +
+

+ Listening on + + + Copy + Copied + +

+
+ )} +
+ Reachable from +
+ { + if ( + next === "localNetworks" || + (next === "allInterfaces" && allNetworksUnlocked) + ) { + changeTarget(next); + } + }} + items={[ + { value: "localNetworks", label: "Local network" }, + { + value: "allInterfaces", + label: "All networks", + disabled: !allNetworksUnlocked, + }, + ]} + /> + {!allNetworksUnlocked && ( +

+ All networks requires reader sign-in. +

+ )} +
+
+
+ + {portMessage && ( +

+ {portMessage} +

+ )} + { + portFocusedRef.current = true; + }} + onBlur={(event) => { + portFocusedRef.current = false; + void commitPort(event); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + onChange={(event) => { + const value = event.currentTarget.value; + if (!/^\d*$/.test(value)) return; + setPortInput(value); + setPortError(null); + // Editing the port acknowledges a failed start, the same + // as leaving the pane: reset the service to stopped. + if (status.state === "error") void controller.stop(); + }} + /> +
+
+ +
+
+
+ +

+ HTTP Basic authentication, supported by most readers. +

+
+ void toggleSignIn(on)} + /> +
+ {sharing.authenticationEnabled && ( + <> +
+ +
+ + setUsernameInput(event.currentTarget.value) + } + onBlur={settleUsername} + /> + {usernameInvalid && ( +

+ Usernames can't contain a colon (:). +

+ )} +
+
+
+ +
+ {/* The pill mounts on this wrapper, outside the + overflow-hidden shell, so it is never clipped. */} +
+
+ + setPassword(event.currentTarget.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + revertPassword(); + } + }} + onBlur={settlePassword} + /> + {passwordWash && passwordRevealed && ( + + )} + + +
+ {holdHint && ( + + Hold to regenerate + + )} +
+ {credentialError && ( +

+ {credentialError.message} +

+ )} +
+
+ + )} +
+
+ ); +}; diff --git a/src/components/ui/SegmentedControl.module.css b/src/components/ui/SegmentedControl.module.css index 81620c6a..20067b86 100644 --- a/src/components/ui/SegmentedControl.module.css +++ b/src/components/ui/SegmentedControl.module.css @@ -26,10 +26,15 @@ transition: background-color 120ms ease-out; } -.item[data-state="off"]:hover { +.item[data-state="off"]:hover:not([data-disabled]) { color: var(--ctd-ink); } +.item[data-disabled] { + color: var(--ctd-control-disabled-text); + cursor: default; +} + .item[data-state="on"] { background-color: var(--ctd-segmented-indicator-bg); box-shadow: 0 0.5px 2px oklch(0% 0 0 / 0.22); diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx index a061954a..0af9760e 100644 --- a/src/components/ui/SegmentedControl.tsx +++ b/src/components/ui/SegmentedControl.tsx @@ -7,6 +7,7 @@ export interface SegmentedControlItem { value: string; label: ReactNode; "aria-label"?: string; + disabled?: boolean; } export interface SegmentedControlProps { @@ -40,6 +41,7 @@ export const SegmentedControl = ({ key={item.value} value={item.value} aria-label={item["aria-label"]} + disabled={item.disabled} className={styles.item} > {item.label} diff --git a/src/lib/hooks/use-opds-sharing.test.ts b/src/lib/hooks/use-opds-sharing.test.ts new file mode 100644 index 00000000..717d3199 --- /dev/null +++ b/src/lib/hooks/use-opds-sharing.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { OpdsServiceStatus } from "@/bindings"; +import { startOutcome } from "@/lib/hooks/use-opds-sharing"; + +const status = (patch: Partial): OpdsServiceStatus => ({ + state: "running", + activeLibraryId: "library", + urls: ["http://192.168.1.5:9028/opds"], + error: null, + config: null, + ...patch, +}); + +describe("startOutcome", () => { + it("treats a running share as success", () => { + expect(startOutcome({ ok: true, value: status({}) })).toEqual({ ok: true }); + }); + + it("treats waiting for an interface as success", () => { + const waiting = status({ state: "waitingForInterface", urls: [] }); + expect(startOutcome({ ok: true, value: waiting })).toEqual({ ok: true }); + }); + + it("treats an error-state status as failure with the backend message", () => { + const failed = status({ + state: "error", + urls: [], + error: { code: "portUnavailable", message: "Port 9030 is in use." }, + }); + expect(startOutcome({ ok: true, value: failed })).toEqual({ + ok: false, + error: { + source: "server", + code: "portUnavailable", + message: "Port 9030 is in use.", + }, + }); + }); + + it("passes a rejected command through", () => { + const rejected = { + ok: false, + error: { source: "server", code: null, message: "Open a library first." }, + } as const; + expect(startOutcome(rejected)).toEqual(rejected); + }); +}); diff --git a/src/lib/hooks/use-opds-sharing.ts b/src/lib/hooks/use-opds-sharing.ts new file mode 100644 index 00000000..335517bf --- /dev/null +++ b/src/lib/hooks/use-opds-sharing.ts @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + OpdsCredentialSecret, + OpdsCredentialStatus, + OpdsErrorCode, + OpdsServiceStatus, + OpdsStartConfig, +} from "@/bindings"; +import type { OpdsClient } from "@/lib/services/opds"; +import { opdsClientForPlatform, tauriOpdsClient } from "@/lib/services/opds"; + +const POLL_INTERVAL_MS = 1_000; +/** Only surface the busy state when an action is actually slow. */ +const ACTING_DELAY_MS = 200; + +/** Which part of the pane an error belongs to, so it renders next to the + * control that caused it. */ +export const SHARING_ERROR_SOURCE = { + server: "server", + credentials: "credentials", +} as const; +export type SharingErrorSource = + (typeof SHARING_ERROR_SOURCE)[keyof typeof SHARING_ERROR_SOURCE]; + +export interface SharingError { + source: SharingErrorSource; + /** Present when the service reported it; rejected commands carry none. */ + code: OpdsErrorCode | null; + message: string; +} + +export type SharingOutcome = { ok: true } | { ok: false; error: SharingError }; + +export type RunResult = + | { ok: true; value: TValue } + | { ok: false; error: SharingError }; + +interface OpdsSharingController { + status: OpdsServiceStatus | null; + credentials: OpdsCredentialStatus; + isLoading: boolean; + isActing: boolean; + /** A failed action's error, else the service's own failure reason. */ + error: SharingError | null; + refresh: () => Promise; + credentialSecret: () => Promise; + start: (config: OpdsStartConfig) => Promise; + reconfigure: (config: OpdsStartConfig) => Promise; + stop: () => Promise; + configureCredentials: (credentials: { + username: string; + password: string; + }) => Promise; + generateCredentials: (username: string) => Promise; + clearCredentials: () => Promise; +} + +const message = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const noClient = (source: SharingErrorSource): RunResult => ({ + ok: false, + error: { + source, + code: null, + message: "Sharing is not available on this platform.", + }, +}); + +/** The service's own failure (a failed bind, a listener that died). */ +const serviceError = (status: OpdsServiceStatus): SharingError | null => { + if (status.state !== "error") return null; + return { + source: SHARING_ERROR_SOURCE.server, + code: status.error?.code ?? null, + message: status.error?.message ?? "Sharing could not start.", + }; +}; + +/** Start and reconfigure report a failed bind as a status in the error + * state, not as a rejected command; both count as failure. */ +export const startOutcome = ( + result: RunResult, +): SharingOutcome => { + if (!result.ok) return result; + const error = serviceError(result.value); + return error ? { ok: false, error } : { ok: true }; +}; + +/** Backend status is authoritative: the pane renders what the service + * reports, never local component state, so a restart elsewhere reads + * correctly here too. */ +export const useOpdsSharing = (supported: boolean): OpdsSharingController => { + const client = useMemo( + () => opdsClientForPlatform(supported, tauriOpdsClient), + [supported], + ); + const [status, setStatus] = useState(null); + const [credentials, setCredentials] = useState({ + configured: false, + username: null, + }); + const [isLoading, setIsLoading] = useState(supported); + const [isActing, setIsActing] = useState(false); + const [error, setError] = useState(null); + const refreshing = useRef(false); + /** A mutation is in flight: the poll must not publish interim states + * (Stopped/Starting) or the pane flaps. */ + const mutating = useRef(false); + /** Bumped by every mutation; polls discard results from before the bump. */ + const mutationSeq = useRef(0); + const actingTimer = useRef(null); + + const beginActing = useCallback(() => { + // Fast operations finish before this fires, so toggles and buttons + // never flash their disabled styling mid-flight. + if (actingTimer.current !== null) window.clearTimeout(actingTimer.current); + actingTimer.current = window.setTimeout(() => { + actingTimer.current = null; + setIsActing(true); + }, ACTING_DELAY_MS); + }, []); + const endActing = useCallback(() => { + if (actingTimer.current !== null) { + window.clearTimeout(actingTimer.current); + actingTimer.current = null; + } + setIsActing(false); + }, []); + + useEffect( + () => () => { + if (actingTimer.current !== null) + window.clearTimeout(actingTimer.current); + }, + [], + ); + + const credentialSecret = + useCallback(async (): Promise => { + if (!client) return null; + try { + return await client.credentialSecret(); + } catch { + return null; + } + }, [client]); + + const refresh = useCallback(async () => { + if (!client || refreshing.current || mutating.current) return; + refreshing.current = true; + const seq = mutationSeq.current; + try { + const [nextStatus, nextCredentials] = await Promise.all([ + client.status(), + client.credentialStatus(), + ]); + // A mutation that landed while this poll was in flight publishes + // its own fresh status; ours is stale by definition. Polls never + // clear mutation errors — those belong to the action that set + // them and outlive a single poll cycle. + if (seq !== mutationSeq.current) return; + setStatus(nextStatus); + setCredentials(nextCredentials); + } catch { + // A failed poll says nothing new; the next one will recover. + } finally { + refreshing.current = false; + setIsLoading(false); + } + }, [client]); + + useEffect(() => { + if (!client) { + setIsLoading(false); + return; + } + void refresh(); + const timer = window.setInterval(() => void refresh(), POLL_INTERVAL_MS); + const handleVisibility = () => { + if (document.visibilityState === "visible") void refresh(); + }; + document.addEventListener("visibilitychange", handleVisibility); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [client, refresh]); + + const run = useCallback( + async ( + source: SharingErrorSource, + action: (client: OpdsClient) => Promise, + ): Promise> => { + if (!client) return noClient(source); + mutating.current = true; + mutationSeq.current += 1; + beginActing(); + setError(null); + try { + const value = await action(client); + setStatus(await client.status()); + setCredentials(await client.credentialStatus()); + return { ok: true, value }; + } catch (cause) { + const failure = { source, code: null, message: message(cause) }; + setError(failure); + return { ok: false, error: failure }; + } finally { + mutating.current = false; + endActing(); + } + }, + [client, beginActing, endActing], + ); + + const start = useCallback( + async (config: OpdsStartConfig): Promise => + startOutcome( + await run(SHARING_ERROR_SOURCE.server, (client) => + client.start(config), + ), + ), + [run], + ); + + const reconfigure = useCallback( + async (config: OpdsStartConfig): Promise => + startOutcome( + await run(SHARING_ERROR_SOURCE.server, (client) => + client.reconfigure(config), + ), + ), + [run], + ); + + const stop = useCallback(async (): Promise => { + await run(SHARING_ERROR_SOURCE.server, (client) => client.stop()); + }, [run]); + + const configureCredentials = useCallback( + async (credentials: { + username: string; + password: string; + }): Promise => { + const result = await run(SHARING_ERROR_SOURCE.credentials, (client) => + client.configureCredentials(credentials), + ); + return result.ok; + }, + [run], + ); + + const clearCredentials = useCallback(async (): Promise => { + await run(SHARING_ERROR_SOURCE.credentials, (client) => + client.clearCredentials(), + ); + }, [run]); + + const generateCredentials = useCallback( + async (username: string): Promise => { + // Generate-and-set: the backend hot-swaps the live credential + // snapshot, so a running share keeps running through rotation. + const generated = await run(SHARING_ERROR_SOURCE.credentials, (client) => + client.generateCredentials(username.trim()), + ); + return generated.ok ? generated.value.password : null; + }, + [run], + ); + + return { + status, + credentials, + isLoading, + isActing, + error: error ?? (status ? serviceError(status) : null), + refresh, + credentialSecret, + start, + reconfigure, + stop, + configureCredentials, + generateCredentials, + clearCredentials, + }; +}; diff --git a/src/lib/platform/create.ts b/src/lib/platform/create.ts index 3f358126..21c39eaf 100644 --- a/src/lib/platform/create.ts +++ b/src/lib/platform/create.ts @@ -18,6 +18,7 @@ export const createTauriPlatform = (): PlatformAdapter => ({ canCopyToClipboard: true, canOpenLocalPaths: true, supportsAutoUpdates: true, + supportsLocalOpdsServer: true, }, dialogs: createTauriDialogs(), clipboard: createTauriClipboard(), @@ -33,6 +34,7 @@ export const createWebPlatform = (): PlatformAdapter => ({ canCopyToClipboard: true, canOpenLocalPaths: false, supportsAutoUpdates: false, + supportsLocalOpdsServer: false, }, dialogs: createWebDialogs(), clipboard: createWebClipboard(), diff --git a/src/lib/platform/settings/migrate.test.ts b/src/lib/platform/settings/migrate.test.ts index 56b559ff..57ac156e 100644 --- a/src/lib/platform/settings/migrate.test.ts +++ b/src/lib/platform/settings/migrate.test.ts @@ -45,7 +45,7 @@ describe("migrateSettings", () => { }, }; const result = migrateSettings(v1); - expect(result.settingsSchemaVersion).toBe(2); + expect(result.settingsSchemaVersion).toBe(4); // K10plus added and enabled, inserted right after DNB. expect(result.metadataProviders.configs.k10plus).toEqual({ enabled: true, @@ -75,4 +75,53 @@ describe("migrateSettings", () => { }; expect(migrateSettings(already)).toBe(already); }); + + it("adds the sharing block to a v2 install", () => { + const v2: SettingsSchema = { + ...defaultSettings, + settingsSchemaVersion: 2, + }; + const result = migrateSettings(v2); + expect(result.settingsSchemaVersion).toBe(4); + expect(result.sharing).toEqual({ + target: "localNetworks", + port: 9028, + authenticationEnabled: false, + username: "", + }); + }); + + it("moves an untouched 8080 sharing port to 9028 in v4", () => { + const v3: SettingsSchema = { + ...defaultSettings, + settingsSchemaVersion: 3, + sharing: { + target: "localNetworks", + port: 8080, + authenticationEnabled: true, + username: "phil", + }, + }; + const result = migrateSettings(v3); + expect(result.settingsSchemaVersion).toBe(4); + expect(result.sharing.port).toBe(9028); + expect(result.sharing.authenticationEnabled).toBe(true); + expect(result.sharing.username).toBe("phil"); + }); + + it("leaves a chosen sharing port alone in v4", () => { + const chosen: SettingsSchema = { + ...defaultSettings, + settingsSchemaVersion: 3, + sharing: { + target: "allInterfaces", + port: 54321, + authenticationEnabled: true, + username: "phil", + }, + }; + const result = migrateSettings(chosen); + expect(result.settingsSchemaVersion).toBe(4); + expect(result.sharing.port).toBe(54321); + }); }); diff --git a/src/lib/platform/settings/migrate.ts b/src/lib/platform/settings/migrate.ts index 97d723f5..155015ef 100644 --- a/src/lib/platform/settings/migrate.ts +++ b/src/lib/platform/settings/migrate.ts @@ -1,7 +1,8 @@ +import { defaultSettings } from "./types"; import type { MetadataProvidersSettings, SettingsSchema } from "./types"; /** The current settings schema version. Bump when the shape changes. */ -export const CURRENT_SCHEMA_VERSION = 2; +export const CURRENT_SCHEMA_VERSION = 4; /** * v0 -> v1: fold the flat `hardcoverApiKey` / `hardcoverAutoLookup` keys into @@ -53,6 +54,42 @@ const migrateV1toV2 = (raw: SettingsSchema): SettingsSchema => { return { ...raw, settingsSchemaVersion: 2, metadataProviders }; }; +/** v2 -> v3: add the OPDS sharing block (local-network mode, port 8080, auth + * off, no username). Existing installs have never shared, so defaults only. */ +const migrateV2toV3 = (raw: SettingsSchema): SettingsSchema => ({ + ...raw, + settingsSchemaVersion: 3, + sharing: { + target: "localNetworks", + port: 8080, + authenticationEnabled: false, + username: "", + }, +}); + +/** v3 -> v4: move the sharing port off the crowded 8080 (Calibre's own + * server's default) to an uncommon high port, and normalize the sharing + * block against partial writes or a tagged `target` shape from an abandoned + * serialization experiment. Only the untouched default port is migrated — a + * port the user actually chose is left alone. */ +const migrateV3toV4 = (raw: SettingsSchema): SettingsSchema => { + const fallback = defaultSettings.sharing; + const sharing = { ...fallback, ...(raw.sharing ?? {}) }; + const rawTarget = sharing.target as unknown; + sharing.target = + rawTarget === "allInterfaces" || rawTarget === "localNetworks" + ? rawTarget + : fallback.target; + if ((raw.sharing?.port ?? 8080) !== 8080) { + return { ...raw, settingsSchemaVersion: 4, sharing }; + } + return { + ...raw, + settingsSchemaVersion: 4, + sharing: { ...sharing, port: 9028 }, + }; +}; + /** * Bring a loaded settings object up to the current schema version by applying * each step in order. Gated on an explicit version, not value-equality with @@ -67,5 +104,11 @@ export const migrateSettings = (raw: SettingsSchema): SettingsSchema => { if (settings.settingsSchemaVersion < 2) { settings = migrateV1toV2(settings); } + if (settings.settingsSchemaVersion < 3) { + settings = migrateV2toV3(settings); + } + if (settings.settingsSchemaVersion < 4) { + settings = migrateV3toV4(settings); + } return settings; }; diff --git a/src/lib/platform/settings/types.ts b/src/lib/platform/settings/types.ts index 89fc56a8..e35c1bce 100644 --- a/src/lib/platform/settings/types.ts +++ b/src/lib/platform/settings/types.ts @@ -41,6 +41,17 @@ export interface MetadataProvidersSettings { } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions +/** Where the OPDS server listens. `allInterfaces` additionally requires + * credentials (see the sharing pane); `localNetworks` never does. */ +export type SharingTarget = "localNetworks" | "allInterfaces"; + +export interface SharingSettings { + target: SharingTarget; + port: number; + authenticationEnabled: boolean; + username: string; +} + export interface SettingsSchema { theme: "dark" | "light" | "auto"; startFullscreen: boolean; @@ -57,6 +68,7 @@ export interface SettingsSchema { /** Bumped when the settings shape changes; gates one-time migrations. */ settingsSchemaVersion: number; metadataProviders: MetadataProvidersSettings; + sharing: SharingSettings; } export const defaultSettings: SettingsSchema = { @@ -90,6 +102,12 @@ export const defaultSettings: SettingsSchema = { }, autoLookupOnImport: false, }, + sharing: { + target: "localNetworks", + port: 9028, + authenticationEnabled: false, + username: "", + }, }; export type SettingsKey = keyof SettingsSchema; diff --git a/src/lib/platform/types.ts b/src/lib/platform/types.ts index c2053e3b..b4d44016 100644 --- a/src/lib/platform/types.ts +++ b/src/lib/platform/types.ts @@ -15,6 +15,8 @@ export interface PlatformCapabilities { canCopyToClipboard: boolean; canOpenLocalPaths: boolean; supportsAutoUpdates: boolean; + /** Only the desktop build can host the local OPDS server. */ + supportsLocalOpdsServer: boolean; } export interface DialogAdapter { diff --git a/src/lib/services/opds.ts b/src/lib/services/opds.ts new file mode 100644 index 00000000..c31812fe --- /dev/null +++ b/src/lib/services/opds.ts @@ -0,0 +1,61 @@ +import type { + GeneratedOpdsCredentials, + OpdsCredentialSecret, + OpdsCredentialStatus, + OpdsServiceStatus, + OpdsStartConfig, + OpdsStatusError, + Result, +} from "@/bindings"; +import { commands } from "@/bindings"; + +export interface OpdsCredentials { + username: string; + password: string; +} + +export interface OpdsClient { + status(): Promise; + start(config: OpdsStartConfig): Promise; + stop(): Promise; + reconfigure(config: OpdsStartConfig): Promise; + credentialStatus(): Promise; + credentialSecret(): Promise; + configureCredentials( + credentials: OpdsCredentials, + ): Promise; + generateCredentials(username: string): Promise; + clearCredentials(): Promise; +} + +const unwrap = (result: Result): T => { + if (result.status === "error") { + throw new Error(result.error.message); + } + return result.data; +}; + +export const tauriOpdsClient: OpdsClient = { + status: async () => unwrap(await commands.clbQueryOpdsStatus()), + start: async (config) => unwrap(await commands.clbCmdStartOpds(config)), + stop: async () => unwrap(await commands.clbCmdStopOpds()), + reconfigure: async (config) => + unwrap(await commands.clbCmdReconfigureOpds(config)), + credentialStatus: async () => + unwrap(await commands.clbQueryOpdsCredentialStatus()), + credentialSecret: async () => + unwrap(await commands.clbQueryOpdsCredentialSecret()), + configureCredentials: async ({ username, password }) => + unwrap(await commands.clbCmdConfigureOpdsCredentials(username, password)), + generateCredentials: async (username) => + unwrap(await commands.clbCmdGenerateOpdsCredentials(username)), + clearCredentials: async () => + unwrap(await commands.clbCmdClearOpdsCredentials()), +}; + +/** A web build gets no client at all, making accidental backend calls harder + * than merely hiding or disabling a button. */ +export const opdsClientForPlatform = ( + supported: boolean, + desktopClient: OpdsClient = tauriOpdsClient, +): OpdsClient | null => (supported ? desktopClient : null); diff --git a/src/stores/settings/store.ts b/src/stores/settings/store.ts index 73250927..cdad393c 100644 --- a/src/stores/settings/store.ts +++ b/src/stores/settings/store.ts @@ -11,6 +11,7 @@ import { type SettingsManager, type SettingsSchema, type SettingsValue, + type SharingSettings, type SmartShelf, type SmartShelfFilter, } from "@/lib/platform/settings/types"; @@ -28,6 +29,7 @@ interface SettingsStore extends SettingsSchema { setAutoUpdateCheckingEnabled: (enabled: boolean) => Promise; setHasCompletedFirstLaunch: (enabled: boolean) => Promise; setActiveLibrary: (libraryId: string) => Promise; + setSharing: (sharing: SharingSettings) => Promise; createLibrary: (absolutePath: string) => Promise; renameLibrary: (id: string, displayName: string) => Promise; getActiveLibrary: () => Option; @@ -235,6 +237,10 @@ export const useSettings = create((set, get) => ({ await persistSetting(set, get, "lastNotifiedUpdateVersion", version); }, + setSharing: async (sharing) => { + await persistSetting(set, get, "sharing", sharing); + }, + createSmartShelf: async (name, filter) => { const { smartShelves } = get(); const trimmed = validateShelfName(name, smartShelves);