1use std::sync::Arc;
4
5use crate::config::Config;
6use crate::remote::client::{Credential, SubsonicAuth, SubsonicClient, SubsonicError};
7
8pub fn remote_credential(cfg: &Config) -> Option<Credential> {
12 if !cfg.remote.api_key.is_empty() {
13 return Some(Credential::ApiKey(cfg.remote.api_key.clone()));
14 }
15 (!cfg.remote.password.is_empty()).then(|| Credential::Password(cfg.remote.password.clone()))
16}
17
18#[derive(Debug, thiserror::Error)]
20pub enum SignInError {
21 #[error("the server did not accept those credentials: {0}")]
22 Rejected(#[from] crate::remote::client::SubsonicError),
23 #[error(
27 "this server needs an app password or API key: make one in the server's web UI and sign in with it"
28 )]
29 NeedsKey,
30 #[error("could not write the configuration: {0}")]
31 Config(#[from] crate::config::ConfigError),
32}
33
34pub fn sign_out() -> Result<(), crate::config::ConfigError> {
40 crate::remote::devices::forget_shares();
41 Config::persist(|cfg| {
42 cfg.remote.enabled = false;
43 cfg.remote.password = String::new();
44 cfg.remote.api_key = String::new();
45 cfg.remote.device_key = String::new();
46 })?;
47 crate::remote::proof::forget();
48 crate::remote::offline::set_manual(false);
49 crate::remote::link::relink();
50 crate::remote::nearby::readvertise();
51 Ok(())
52}
53
54pub fn set_remote_credentials(
71 url: &str,
72 username: &str,
73 password: &str,
74) -> Result<(), SignInError> {
75 use crate::remote::client::{koan_sign_in, offers_unsigned};
76 let url = url.trim_end_matches('/');
77 if offers_unsigned(url, crate::remote::profile::SIGN_IN).unwrap_or(false) {
80 let device = crate::remote::link::LinkIdentity::this_device(None).name;
81 match koan_sign_in(url, username, password, &device) {
82 Ok(joined) => return adopt_api_key(url, &joined.username, &joined.api_key),
83 Err(SubsonicError::Api { code: 50, .. }) => {}
87 Err(e) => return Err(rejected(e)),
88 }
89 }
90 SubsonicClient::new(url, username, password)
91 .ping()
92 .map_err(rejected)?;
93
94 remember_remote(url, username, Credential::Password(password.to_string()))
95}
96
97pub fn set_remote_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
101 let url = url.trim_end_matches('/');
102 let credential = Credential::ApiKey(api_key.to_string());
103 SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential.clone())).ping()?;
104 remember_remote(url, username, credential)
105}
106
107pub fn change_own_password(current: &str, password: &str) -> Result<(), SignInError> {
112 let cfg = Config::load()?;
113 let client = subsonic_client(&cfg).ok_or(SignInError::Rejected(SubsonicError::BadResponse))?;
114 let device = crate::remote::link::LinkIdentity::this_device(None).name;
115 let joined = client.koan_change_own_password(current, password, &device)?;
116 adopt_api_key(&cfg.remote.url, &joined.username, &joined.api_key)
117}
118
119fn rejected(e: SubsonicError) -> SignInError {
121 match e {
122 SubsonicError::Api { code: 41, .. } => SignInError::NeedsKey,
123 e => SignInError::Rejected(e),
124 }
125}
126
127pub fn join_with_invite(invite: &crate::invite::Invite) -> Result<(), SignInError> {
131 let url = invite.server.trim_end_matches('/');
132 match (&invite.token, &invite.password) {
133 (Some(token), _) => {
134 let device = crate::remote::link::LinkIdentity::this_device(None).name;
135 let joined = crate::remote::client::redeem_invite(url, token, &device)?;
136 adopt_api_key(url, &joined.username, &joined.api_key)
137 }
138 (None, Some(password)) => set_remote_credentials(url, &invite.username, password),
139 (None, None) => Err(SignInError::Rejected(SubsonicError::BadResponse)),
140 }
141}
142
143pub(crate) fn adopt_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
148 let url = url.trim_end_matches('/');
149 let replaced = Config::load()
150 .ok()
151 .filter(|c| c.remote.url.trim_end_matches('/') == url)
152 .filter(|c| c.remote.username == username)
153 .map(|c| c.remote.api_key)
154 .filter(|k| !k.is_empty() && k != api_key);
155 let revoke = |key: &str| {
157 let credential = Credential::ApiKey(key.to_string());
158 let client = SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential));
159 if let Err(e) = client.koan_revoke_own_key() {
160 log::warn!("could not revoke an unused API key: {e}");
161 }
162 };
163 if let Err(e) = remember_remote(url, username, Credential::ApiKey(api_key.to_string())) {
164 revoke(api_key);
165 return Err(e);
166 }
167 if let Some(old) = replaced {
168 revoke(&old);
169 }
170 Ok(())
171}
172
173fn remember_remote(url: &str, username: &str, credential: Credential) -> Result<(), SignInError> {
176 Config::persist(|cfg| {
177 cfg.remote.enabled = true;
178 cfg.remote.url = url.to_string();
179 cfg.remote.username = username.to_string();
180 (cfg.remote.password, cfg.remote.api_key) = match &credential {
181 Credential::Password(p) => (p.clone(), String::new()),
182 Credential::ApiKey(k) => (String::new(), k.clone()),
183 };
184 cfg.remote.device_key = match &credential {
187 Credential::ApiKey(_) => crate::remote::proof::new_device_key().unwrap_or_default(),
188 Credential::Password(_) => String::new(),
189 };
190 })?;
191 crate::remote::proof::forget();
194 crate::remote::link::relink();
195 crate::remote::nearby::readvertise();
197 Ok(())
198}
199
200pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
204 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
205}
206
207pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
214 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
215 return None;
216 }
217 Some(SubsonicAuth::with(
218 &cfg.remote.url,
219 &cfg.remote.username,
220 remote_credential(cfg)?,
221 ))
222}
223
224pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
235 let auth = subsonic_auth(cfg)?;
236
237 let mut slot = SUBSONIC_CLIENT.lock();
238 if let Some((cached, client)) = slot.as_ref()
239 && *cached == auth
240 {
241 return Some(client.clone());
242 }
243
244 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
245 *slot = Some((auth, client.clone()));
246 Some(client)
247}
248
249pub fn server_unreachable() -> Option<String> {
252 let client = SUBSONIC_CLIENT.lock().as_ref().map(|(_, c)| c.clone())?;
253 client.outage().reason()
254}
255
256pub fn network_changed() {
261 let client = SUBSONIC_CLIENT.lock().as_ref().map(|(_, c)| c.clone());
262 if let Some(client) = client {
263 client.renew();
264 }
265 crate::remote::devices::resume();
266}
267
268type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
269
270static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
271 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
272
273pub fn remote_unavailable(cfg: &Config) -> String {
279 if !cfg.remote.enabled {
280 return "no remote server is configured".into();
281 }
282 if cfg.remote.url.is_empty() {
283 return "the remote server has no address".into();
284 }
285 if remote_credential(cfg).is_none() {
286 return "no password or API key is stored for the remote server".into();
287 }
288 "the remote server could not be reached".into()
291}
292
293pub const SIGN_IN_REFUSED: &str = "the remote server refused the stored sign-in; sign in again";
295
296pub fn remote_problem(cfg: &Config) -> Option<String> {
301 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
302 return None;
303 }
304 match subsonic_auth(cfg) {
305 None => Some(remote_unavailable(cfg)),
306 Some(auth) if crate::remote::refusal::refused(&auth) => Some(SIGN_IN_REFUSED.into()),
307 Some(_) => None,
308 }
309}
310
311pub fn sign_in_refused(cfg: &Config) -> bool {
313 subsonic_auth(cfg).is_some_and(|auth| crate::remote::refusal::refused(&auth))
314}
315
316#[cfg(test)]
317mod client_cache_tests {
318 use super::*;
319
320 #[test]
321 fn one_subsonic_client_is_shared_per_credentials() {
322 crate::config::isolate_config_for_tests();
323 let mut cfg = Config::default();
324 cfg.remote.enabled = true;
325 cfg.remote.url = "https://shared-client.invalid".into();
326 cfg.remote.username = "koan".into();
327 cfg.remote.password = "first".into();
328
329 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
330 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
331 assert!(
332 Arc::ptr_eq(&first, &again),
333 "rebuilding drops the connection pool and re-handshakes TLS per request"
334 );
335
336 cfg.remote.password = "second".into();
337 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
338 assert!(
339 !Arc::ptr_eq(&first, &relogged),
340 "new credentials must not keep serving the client signed with the old ones"
341 );
342 }
343}
344
345#[cfg(test)]
346mod sign_in_tests {
347 use super::*;
348
349 fn serve() -> String {
354 use std::io::{BufRead, Write};
355 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
356 let url = format!("http://{}", listener.local_addr().unwrap());
357 std::thread::spawn(move || {
358 for mut stream in listener.incoming().flatten() {
359 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
360 let mut request = String::new();
361 reader.read_line(&mut request).unwrap();
362 let mut line = String::new();
363 while reader.read_line(&mut line).unwrap_or(0) > 2 {
364 line.clear();
365 }
366 let target = request.split_whitespace().nth(1).unwrap_or("");
367 let (path, query) = target.split_once('?').unwrap_or((target, ""));
368 let param = |name: &str| {
369 query
370 .split('&')
371 .filter_map(|kv| kv.split_once('='))
372 .find(|(k, _)| *k == name)
373 .map(|(_, v)| v.replace("%3A", ":"))
374 .unwrap_or_default()
375 };
376 let secret = match param("u").as_str() {
377 "mate" => "app-secret",
378 "testuser" => "shared-secret",
379 _ => "",
380 };
381 let ok = r#"{"subsonic-response":{"status":"ok"}}"#.to_owned();
382 let refused = |code: i32| {
383 format!(
384 r#"{{"subsonic-response":{{"status":"failed","error":{{"code":{code},"message":"refused"}}}}}}"#
385 )
386 };
387 let body = match path.rsplit('/').next().unwrap() {
388 "getOpenSubsonicExtensions" => r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned(),
389 "koanSignIn" => {
390 let hex = param("p").trim_start_matches("enc:").to_owned();
391 let typed: String = (0..hex.len())
392 .step_by(2)
393 .filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
394 .map(char::from)
395 .collect();
396 match (param("u").as_str(), typed.as_str()) {
397 ("mate", "hunter22") => r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"minted"}}}"#.to_owned(),
398 (_, typed) if typed == secret => refused(50),
399 _ => refused(40),
400 }
401 }
402 "ping" if param("apiKey") == "minted" => ok,
403 "ping" => {
404 let expected =
405 format!("{:x}", md5::compute(format!("{secret}{}", param("s"))));
406 if !secret.is_empty() && param("t") == expected {
407 ok
408 } else {
409 refused(40)
410 }
411 }
412 _ => ok,
413 };
414 let _ = write!(
415 stream,
416 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
417 body.len()
418 );
419 }
420 });
421 url
422 }
423
424 #[test]
425 fn a_password_ends_in_a_key_and_other_credentials_are_kept_as_typed() {
426 let _guard = crate::config::tests::PERSIST_LOCK
427 .lock()
428 .unwrap_or_else(|e| e.into_inner());
429 let dir = tempfile::tempdir().unwrap();
430 crate::config::set_config_dir(dir.path());
431 let url = serve();
432 let kept = || {
433 let remote = Config::load().unwrap().remote;
434 (remote.username, remote.password, remote.api_key)
435 };
436
437 set_remote_credentials(&url, "mate", "hunter22").unwrap();
438 assert_eq!(kept(), ("mate".into(), String::new(), "minted".into()));
439
440 set_remote_credentials(&url, "mate", "app-secret").unwrap();
441 assert_eq!(
442 kept(),
443 ("mate".into(), "app-secret".into(), String::new()),
444 "an app password is refused a key and kept"
445 );
446
447 set_remote_credentials(&url, "testuser", "shared-secret").unwrap();
448 assert_eq!(
449 kept(),
450 ("testuser".into(), "shared-secret".into(), String::new()),
451 "the shared secret is refused a key and kept"
452 );
453
454 assert!(matches!(
455 set_remote_credentials(&url, "mate", "wrong"),
456 Err(SignInError::Rejected(SubsonicError::Api { code: 40, .. }))
457 ));
458 assert_eq!(
459 kept().1,
460 "shared-secret",
461 "a refused sign-in writes nothing"
462 );
463 }
464}
465
466#[cfg(test)]
467mod refusal_tests {
468 use super::*;
469 use crate::db::connection::Database;
470 use crate::helpers::*;
471 use std::collections::HashSet;
472 use std::sync::Mutex;
473
474 fn serve(keys: Arc<Mutex<HashSet<String>>>) -> String {
477 use std::io::{BufRead, Write};
478 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
479 let url = format!("http://{}", listener.local_addr().unwrap());
480 std::thread::spawn(move || {
481 for mut stream in listener.incoming().flatten() {
482 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
483 let mut request = String::new();
484 reader.read_line(&mut request).unwrap();
485 let mut line = String::new();
486 while reader.read_line(&mut line).unwrap_or(0) > 2 {
487 line.clear();
488 }
489 let target = request.split_whitespace().nth(1).unwrap_or("");
490 let (path, query) = target.split_once('?').unwrap_or((target, ""));
491 let param = |name: &str| {
492 query
493 .split('&')
494 .filter_map(|kv| kv.split_once('='))
495 .find(|(k, _)| *k == name)
496 .map(|(_, v)| v.to_owned())
497 .unwrap_or_default()
498 };
499 let endpoint = path.rsplit('/').next().unwrap();
500 let body = if endpoint == "koanSignIn" {
501 keys.lock().unwrap().insert("second".into());
502 r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"second"}}}"#.to_owned()
503 } else if endpoint == "getOpenSubsonicExtensions" {
504 r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned()
505 } else if !keys.lock().unwrap().contains(¶m("apiKey")) {
506 r#"{"subsonic-response":{"status":"failed","error":{"code":44,"message":"invalid API key"}}}"#.to_owned()
507 } else {
508 r#"{"subsonic-response":{"status":"ok","indexes":{"lastModified":1}}}"#
509 .to_owned()
510 };
511 let _ = write!(
512 stream,
513 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
514 body.len()
515 );
516 }
517 });
518 url
519 }
520
521 #[test]
524 fn signing_out_forgets_the_account_and_offline_mode() {
525 let _guard = crate::config::tests::PERSIST_LOCK
526 .lock()
527 .unwrap_or_else(|e| e.into_inner());
528 let dir = tempfile::tempdir().unwrap();
529 crate::config::set_config_dir(dir.path());
530 Config::persist(|c| {
531 c.remote.enabled = true;
532 c.remote.url = "https://music.example.com".into();
533 c.remote.username = "mate".into();
534 c.remote.api_key = "key".into();
535 })
536 .unwrap();
537 crate::remote::offline::set_manual(true);
538
539 sign_out().unwrap();
540 let cfg = Config::load().unwrap();
541 assert!(!cfg.remote.enabled);
542 assert!(cfg.remote.api_key.is_empty());
543 assert_eq!(remote_credential(&cfg).map(|_| ()), None);
544 assert!(!crate::remote::offline::manual());
545 }
546
547 #[test]
548 fn a_revoked_key_is_reported_until_signing_in_again() {
549 let _guard = crate::config::tests::PERSIST_LOCK
550 .lock()
551 .unwrap_or_else(|e| e.into_inner());
552 let dir = tempfile::tempdir().unwrap();
553 crate::config::set_config_dir(dir.path());
554 let keys = Arc::new(Mutex::new(HashSet::from(["first".to_owned()])));
555 let url = serve(keys.clone());
556 Config::persist(|c| {
557 c.remote.enabled = true;
558 c.remote.url = url.clone();
559 c.remote.username = "mate".into();
560 c.remote.api_key = "first".into();
561 })
562 .unwrap();
563
564 let db = Database::open(&dir.path().join("koan.db")).unwrap();
565 let sync = || {
566 let cfg = Config::load().unwrap();
567 let client = SubsonicClient::from_auth(subsonic_auth(&cfg).unwrap());
568 let _ = sync_remote(&db, &client, Walk::IfChanged, &url, "mate", &|_| {});
569 };
570
571 sync();
572 assert_eq!(remote_problem(&Config::load().unwrap()), None);
573
574 keys.lock().unwrap().remove("first");
575 sync();
576 let cfg = Config::load().unwrap();
577 assert_eq!(remote_problem(&cfg).as_deref(), Some(SIGN_IN_REFUSED));
578 assert!(sign_in_refused(&cfg));
579
580 set_remote_credentials(&url, "mate", "hunter22").unwrap();
581 let cfg = Config::load().unwrap();
582 assert_eq!(cfg.remote.api_key, "second");
583 assert_eq!(remote_problem(&cfg), None, "a new credential starts clean");
584 sync();
585 assert_eq!(remote_problem(&Config::load().unwrap()), None);
586 }
587}