use std::sync::Arc;
use crate::config::Config;
use crate::remote::client::{Credential, SubsonicAuth, SubsonicClient, SubsonicError};
pub fn remote_credential(cfg: &Config) -> Option<Credential> {
if !cfg.remote.api_key.is_empty() {
return Some(Credential::ApiKey(cfg.remote.api_key.clone()));
}
(!cfg.remote.password.is_empty()).then(|| Credential::Password(cfg.remote.password.clone()))
}
#[derive(Debug, thiserror::Error)]
pub enum SignInError {
#[error("the server did not accept those credentials: {0}")]
Rejected(#[from] crate::remote::client::SubsonicError),
#[error(
"this server needs an app password or API key: make one in the server's web UI and sign in with it"
)]
NeedsKey,
#[error("could not write the configuration: {0}")]
Config(#[from] crate::config::ConfigError),
}
pub fn set_remote_credentials(
url: &str,
username: &str,
password: &str,
) -> Result<(), SignInError> {
use crate::remote::client::{koan_sign_in, offers_unsigned};
let url = url.trim_end_matches('/');
if offers_unsigned(url, crate::remote::profile::SIGN_IN).unwrap_or(false) {
let device = crate::remote::link::LinkIdentity::this_device(None).name;
match koan_sign_in(url, username, password, &device) {
Ok(joined) => return adopt_api_key(url, &joined.username, &joined.api_key),
Err(SubsonicError::Api { code: 50, .. }) => {}
Err(e) => return Err(rejected(e)),
}
}
SubsonicClient::new(url, username, password)
.ping()
.map_err(rejected)?;
remember_remote(url, username, Credential::Password(password.to_string()))
}
pub fn set_remote_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
let url = url.trim_end_matches('/');
let credential = Credential::ApiKey(api_key.to_string());
SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential.clone())).ping()?;
remember_remote(url, username, credential)
}
pub fn change_own_password(current: &str, password: &str) -> Result<(), SignInError> {
let cfg = Config::load()?;
let client = subsonic_client(&cfg).ok_or(SignInError::Rejected(SubsonicError::BadResponse))?;
let device = crate::remote::link::LinkIdentity::this_device(None).name;
let joined = client.koan_change_own_password(current, password, &device)?;
adopt_api_key(&cfg.remote.url, &joined.username, &joined.api_key)
}
fn rejected(e: SubsonicError) -> SignInError {
match e {
SubsonicError::Api { code: 41, .. } => SignInError::NeedsKey,
e => SignInError::Rejected(e),
}
}
pub fn join_with_invite(invite: &crate::invite::Invite) -> Result<(), SignInError> {
let url = invite.server.trim_end_matches('/');
match (&invite.token, &invite.password) {
(Some(token), _) => {
let device = crate::remote::link::LinkIdentity::this_device(None).name;
let joined = crate::remote::client::redeem_invite(url, token, &device)?;
adopt_api_key(url, &joined.username, &joined.api_key)
}
(None, Some(password)) => set_remote_credentials(url, &invite.username, password),
(None, None) => Err(SignInError::Rejected(SubsonicError::BadResponse)),
}
}
pub(crate) fn adopt_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
let url = url.trim_end_matches('/');
let replaced = Config::load()
.ok()
.filter(|c| c.remote.url.trim_end_matches('/') == url)
.filter(|c| c.remote.username == username)
.map(|c| c.remote.api_key)
.filter(|k| !k.is_empty() && k != api_key);
let revoke = |key: &str| {
let credential = Credential::ApiKey(key.to_string());
let client = SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential));
if let Err(e) = client.koan_revoke_own_key() {
log::warn!("could not revoke an unused API key: {e}");
}
};
if let Err(e) = remember_remote(url, username, Credential::ApiKey(api_key.to_string())) {
revoke(api_key);
return Err(e);
}
if let Some(old) = replaced {
revoke(&old);
}
Ok(())
}
fn remember_remote(url: &str, username: &str, credential: Credential) -> Result<(), SignInError> {
Config::persist(|cfg| {
cfg.remote.enabled = true;
cfg.remote.url = url.to_string();
cfg.remote.username = username.to_string();
(cfg.remote.password, cfg.remote.api_key) = match &credential {
Credential::Password(p) => (p.clone(), String::new()),
Credential::ApiKey(k) => (String::new(), k.clone()),
};
cfg.remote.device_key = match &credential {
Credential::ApiKey(_) => crate::remote::proof::new_device_key().unwrap_or_default(),
Credential::Password(_) => String::new(),
};
})?;
crate::remote::proof::forget();
crate::remote::link::relink();
crate::remote::nearby::readvertise();
Ok(())
}
pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
(!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
}
pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
if !cfg.remote.enabled || cfg.remote.url.is_empty() {
return None;
}
Some(SubsonicAuth::with(
&cfg.remote.url,
&cfg.remote.username,
remote_credential(cfg)?,
))
}
pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
let auth = subsonic_auth(cfg)?;
let mut slot = SUBSONIC_CLIENT.lock();
if let Some((cached, client)) = slot.as_ref()
&& *cached == auth
{
return Some(client.clone());
}
let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
*slot = Some((auth, client.clone()));
Some(client)
}
type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
pub fn remote_unavailable(cfg: &Config) -> String {
if !cfg.remote.enabled {
return "no remote server is configured".into();
}
if cfg.remote.url.is_empty() {
return "the remote server has no address".into();
}
if remote_credential(cfg).is_none() {
return "no password or API key is stored for the remote server".into();
}
"the remote server could not be reached".into()
}
pub const SIGN_IN_REFUSED: &str = "the remote server refused the stored sign-in; sign in again";
pub fn remote_problem(cfg: &Config) -> Option<String> {
if !cfg.remote.enabled || cfg.remote.url.is_empty() {
return None;
}
match subsonic_auth(cfg) {
None => Some(remote_unavailable(cfg)),
Some(auth) if crate::remote::refusal::refused(&auth) => Some(SIGN_IN_REFUSED.into()),
Some(_) => None,
}
}
pub fn sign_in_refused(cfg: &Config) -> bool {
subsonic_auth(cfg).is_some_and(|auth| crate::remote::refusal::refused(&auth))
}
#[cfg(test)]
mod client_cache_tests {
use super::*;
#[test]
fn one_subsonic_client_is_shared_per_credentials() {
crate::config::isolate_config_for_tests();
let mut cfg = Config::default();
cfg.remote.enabled = true;
cfg.remote.url = "https://shared-client.invalid".into();
cfg.remote.username = "koan".into();
cfg.remote.password = "first".into();
let first = subsonic_client(&cfg).expect("a configured remote yields a client");
let again = subsonic_client(&cfg).expect("a configured remote yields a client");
assert!(
Arc::ptr_eq(&first, &again),
"rebuilding drops the connection pool and re-handshakes TLS per request"
);
cfg.remote.password = "second".into();
let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
assert!(
!Arc::ptr_eq(&first, &relogged),
"new credentials must not keep serving the client signed with the old ones"
);
}
}
#[cfg(test)]
mod sign_in_tests {
use super::*;
fn serve() -> String {
use std::io::{BufRead, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
let mut request = String::new();
reader.read_line(&mut request).unwrap();
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 2 {
line.clear();
}
let target = request.split_whitespace().nth(1).unwrap_or("");
let (path, query) = target.split_once('?').unwrap_or((target, ""));
let param = |name: &str| {
query
.split('&')
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.replace("%3A", ":"))
.unwrap_or_default()
};
let secret = match param("u").as_str() {
"mate" => "app-secret",
"testuser" => "shared-secret",
_ => "",
};
let ok = r#"{"subsonic-response":{"status":"ok"}}"#.to_owned();
let refused = |code: i32| {
format!(
r#"{{"subsonic-response":{{"status":"failed","error":{{"code":{code},"message":"refused"}}}}}}"#
)
};
let body = match path.rsplit('/').next().unwrap() {
"getOpenSubsonicExtensions" => r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned(),
"koanSignIn" => {
let hex = param("p").trim_start_matches("enc:").to_owned();
let typed: String = (0..hex.len())
.step_by(2)
.filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
.map(char::from)
.collect();
match (param("u").as_str(), typed.as_str()) {
("mate", "hunter22") => r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"minted"}}}"#.to_owned(),
(_, typed) if typed == secret => refused(50),
_ => refused(40),
}
}
"ping" if param("apiKey") == "minted" => ok,
"ping" => {
let expected =
format!("{:x}", md5::compute(format!("{secret}{}", param("s"))));
if !secret.is_empty() && param("t") == expected {
ok
} else {
refused(40)
}
}
_ => ok,
};
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
}
});
url
}
#[test]
fn a_password_ends_in_a_key_and_other_credentials_are_kept_as_typed() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
crate::config::set_config_dir(dir.path());
let url = serve();
let kept = || {
let remote = Config::load().unwrap().remote;
(remote.username, remote.password, remote.api_key)
};
set_remote_credentials(&url, "mate", "hunter22").unwrap();
assert_eq!(kept(), ("mate".into(), String::new(), "minted".into()));
set_remote_credentials(&url, "mate", "app-secret").unwrap();
assert_eq!(
kept(),
("mate".into(), "app-secret".into(), String::new()),
"an app password is refused a key and kept"
);
set_remote_credentials(&url, "testuser", "shared-secret").unwrap();
assert_eq!(
kept(),
("testuser".into(), "shared-secret".into(), String::new()),
"the shared secret is refused a key and kept"
);
assert!(matches!(
set_remote_credentials(&url, "mate", "wrong"),
Err(SignInError::Rejected(SubsonicError::Api { code: 40, .. }))
));
assert_eq!(
kept().1,
"shared-secret",
"a refused sign-in writes nothing"
);
}
}
#[cfg(test)]
mod refusal_tests {
use super::*;
use crate::db::connection::Database;
use crate::helpers::*;
use std::collections::HashSet;
use std::sync::Mutex;
fn serve(keys: Arc<Mutex<HashSet<String>>>) -> String {
use std::io::{BufRead, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
let mut request = String::new();
reader.read_line(&mut request).unwrap();
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 2 {
line.clear();
}
let target = request.split_whitespace().nth(1).unwrap_or("");
let (path, query) = target.split_once('?').unwrap_or((target, ""));
let param = |name: &str| {
query
.split('&')
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_owned())
.unwrap_or_default()
};
let endpoint = path.rsplit('/').next().unwrap();
let body = if endpoint == "koanSignIn" {
keys.lock().unwrap().insert("second".into());
r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"second"}}}"#.to_owned()
} else if endpoint == "getOpenSubsonicExtensions" {
r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned()
} else if !keys.lock().unwrap().contains(¶m("apiKey")) {
r#"{"subsonic-response":{"status":"failed","error":{"code":44,"message":"invalid API key"}}}"#.to_owned()
} else {
r#"{"subsonic-response":{"status":"ok","indexes":{"lastModified":1}}}"#
.to_owned()
};
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
}
});
url
}
#[test]
fn a_revoked_key_is_reported_until_signing_in_again() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
crate::config::set_config_dir(dir.path());
let keys = Arc::new(Mutex::new(HashSet::from(["first".to_owned()])));
let url = serve(keys.clone());
Config::persist(|c| {
c.remote.enabled = true;
c.remote.url = url.clone();
c.remote.username = "mate".into();
c.remote.api_key = "first".into();
})
.unwrap();
let db = Database::open(&dir.path().join("koan.db")).unwrap();
let sync = || {
let cfg = Config::load().unwrap();
let client = SubsonicClient::from_auth(subsonic_auth(&cfg).unwrap());
let _ = sync_remote(&db, &client, Walk::IfChanged, &url, "mate", &|_| {});
};
sync();
assert_eq!(remote_problem(&Config::load().unwrap()), None);
keys.lock().unwrap().remove("first");
sync();
let cfg = Config::load().unwrap();
assert_eq!(remote_problem(&cfg).as_deref(), Some(SIGN_IN_REFUSED));
assert!(sign_in_refused(&cfg));
set_remote_credentials(&url, "mate", "hunter22").unwrap();
let cfg = Config::load().unwrap();
assert_eq!(cfg.remote.api_key, "second");
assert_eq!(remote_problem(&cfg), None, "a new credential starts clean");
sync();
assert_eq!(remote_problem(&Config::load().unwrap()), None);
}
}