use std::net::{Shutdown, TcpStream};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tungstenite::stream::MaybeTlsStream;
use url::{Url, form_urlencoded};
use crate::config::Config;
use crate::helpers::SignInError;
use crate::remote::client::SubsonicError;
pub const PAIR_PAGE: &str = "https://koan.rocks/pair/";
const SILENCE: Duration = Duration::from_secs(90);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PairMessage {
#[serde(rename_all = "camelCase")]
Pending {
id: String,
code: String,
expires_in: u64,
},
#[serde(rename_all = "camelCase")]
Approved {
username: String,
api_key: String,
},
Declined,
Expired,
}
#[derive(Debug, thiserror::Error)]
pub enum PairError {
#[error("not an http(s) server address: {0}")]
BadUrl(String),
#[error("could not reach the server: {0}")]
Connect(String),
#[error("this server does not pair devices")]
Unsupported,
#[error("too many devices are waiting to pair from this network; try again in a few minutes")]
TooMany,
#[error("the server answered with something unexpected")]
BadResponse,
#[error("the sign-in was declined")]
Declined,
#[error("the code expired before anyone approved it")]
Expired,
#[error("the connection to the server closed")]
Closed,
#[error("no server is signed in")]
NotSignedIn,
#[error(transparent)]
Remote(#[from] SubsonicError),
#[error(transparent)]
SignIn(#[from] SignInError),
}
type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
pub struct Pending {
pub id: String,
pub code: String,
pub link: String,
server: String,
socket: Socket,
}
pub struct Cancel(TcpStream);
impl Cancel {
pub fn cancel(&self) {
let _ = self.0.shutdown(Shutdown::Both);
}
}
pub fn start(url: &str, device: &str) -> Result<Pending, PairError> {
let server = url.trim().trim_end_matches('/').to_owned();
let socket_url = pair_url(&server, device)?;
let (mut socket, _) = tungstenite::connect(socket_url.as_str()).map_err(|e| match e {
tungstenite::Error::Http(ref response) => match response.status().as_u16() {
429 => PairError::TooMany,
502..=504 => PairError::Connect(e.to_string()),
_ => PairError::Unsupported,
},
e => PairError::Connect(e.to_string()),
})?;
if let Some(tcp) = tcp(&socket) {
let _ = tcp.set_read_timeout(Some(SILENCE));
}
match read(&mut socket)? {
PairMessage::Pending { id, code, .. } => Ok(Pending {
link: link(&server, &id),
id,
code,
server,
socket,
}),
_ => Err(PairError::BadResponse),
}
}
impl Pending {
pub fn canceller(&self) -> Option<Cancel> {
tcp(&self.socket)?.try_clone().ok().map(Cancel)
}
pub fn wait(mut self) -> Result<(), PairError> {
let outcome = read(&mut self.socket);
let _ = self.socket.close(None);
match outcome? {
PairMessage::Approved { username, api_key } => {
crate::helpers::adopt_api_key(&self.server, &username, &api_key)?;
Ok(())
}
PairMessage::Declined => Err(PairError::Declined),
PairMessage::Expired => Err(PairError::Expired),
PairMessage::Pending { .. } => Err(PairError::BadResponse),
}
}
}
fn read(socket: &mut Socket) -> Result<PairMessage, PairError> {
loop {
match socket.read() {
Ok(tungstenite::Message::Text(text)) => {
return serde_json::from_str(&text).map_err(|_| PairError::BadResponse);
}
Ok(tungstenite::Message::Close(_)) => return Err(PairError::Closed),
Ok(_) => {}
Err(_) => return Err(PairError::Closed),
}
}
}
fn tcp(socket: &Socket) -> Option<&TcpStream> {
match socket.get_ref() {
MaybeTlsStream::Plain(s) => Some(s),
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
_ => None,
}
}
fn pair_url(server: &str, device: &str) -> Result<Url, PairError> {
let bad = || PairError::BadUrl(server.to_owned());
let mut url = Url::parse(&format!("{server}/rest/koanPair")).map_err(|_| bad())?;
let scheme = match url.scheme() {
"https" => "wss",
"http" => "ws",
_ => return Err(bad()),
};
url.set_scheme(scheme).map_err(|()| bad())?;
url.query_pairs_mut().append_pair("name", device);
Ok(url)
}
pub fn link(server: &str, id: &str) -> String {
let fragment = form_urlencoded::Serializer::new(String::new())
.append_pair("s", server)
.append_pair("p", id)
.finish();
format!("{PAIR_PAGE}#{fragment}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairLink {
pub server: String,
pub id: String,
}
impl PairLink {
pub fn parse(link: &str) -> Option<Self> {
let url = Url::parse(link.trim()).ok()?;
let params = match (url.scheme(), url.host_str()) {
("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/pair" => {
url.fragment()?
}
("koan", Some("pair")) => url.fragment().or(url.query())?,
_ => return None,
};
let (mut server, mut id) = (None, None);
for (k, v) in form_urlencoded::parse(params.as_bytes()) {
let v = Some(v.trim().to_owned()).filter(|v| !v.is_empty());
match &*k {
"s" => server = v,
"p" => id = v,
_ => {}
}
}
let server = server?.trim_end_matches('/').to_owned();
if !matches!(Url::parse(&server).ok()?.scheme(), "http" | "https") {
return None;
}
Some(Self { server, id: id? })
}
}
fn client() -> Result<std::sync::Arc<crate::remote::client::SubsonicClient>, PairError> {
crate::helpers::subsonic_client(&Config::load().unwrap_or_default())
.ok_or(PairError::NotSignedIn)
}
pub fn info(pair: &str) -> Result<crate::remote::client::KoanPair, PairError> {
Ok(client()?.koan_pair_info(pair)?)
}
pub fn approve(pair: &str) -> Result<String, PairError> {
Ok(client()?.koan_pair_approve(pair, false)?)
}
pub fn decline(pair: &str) -> Result<String, PairError> {
Ok(client()?.koan_pair_approve(pair, true)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_link_reads_back() {
let made = link("https://music.example.com:8443/koan", "abc_-123");
assert!(made.starts_with("https://koan.rocks/pair/#s=https%3A%2F%2Fmusic"));
assert_eq!(
PairLink::parse(&made),
Some(PairLink {
server: "https://music.example.com:8443/koan".into(),
id: "abc_-123".into(),
})
);
assert!(PairLink::parse("https://koan.rocks/pair#s=https%3A%2F%2Fa.example&p=x").is_some());
}
#[test]
fn the_apps_own_scheme_carries_the_same_link() {
let expected = Some(PairLink {
server: "https://a.example".into(),
id: "x".into(),
});
assert_eq!(
PairLink::parse("koan://pair#s=https%3A%2F%2Fa.example&p=x"),
expected
);
assert_eq!(
PairLink::parse("koan://pair?s=https%3A%2F%2Fa.example&p=x"),
expected
);
assert_eq!(
PairLink::parse("koan://join?s=https%3A%2F%2Fa.example&p=x"),
None
);
}
#[test]
fn other_links_are_not_pairings() {
for link in [
"https://koan.rocks/join/#s=https%3A%2F%2Fa.example&p=x",
"https://evil.example/pair/#s=https%3A%2F%2Fa.example&p=x",
"https://koan.rocks/pair/#s=javascript%3Aalert(1)&p=x",
"https://koan.rocks/pair/#s=https%3A%2F%2Fa.example",
"https://koan.rocks/pair/#p=x",
] {
assert_eq!(PairLink::parse(link), None, "{link}");
}
}
#[test]
fn the_socket_address_follows_the_scheme() {
let url = pair_url("https://music.example.com/koan", "Living room TV").unwrap();
assert_eq!(
url.as_str(),
"wss://music.example.com/koan/rest/koanPair?name=Living+room+TV"
);
let url = pair_url("http://10.0.0.2:4533", "tv").unwrap();
assert_eq!(url.scheme(), "ws");
assert!(pair_url("ftp://x", "tv").is_err());
}
#[test]
fn info_carries_where_the_request_came_from() {
let info: crate::remote::client::KoanPair =
serde_json::from_str(r#"{"device":"Den TV","from":"192.168.1.20","local":true}"#)
.unwrap();
assert_eq!((info.from.as_str(), info.local), ("192.168.1.20", true));
}
#[test]
fn messages_are_the_wire_format() {
let pending: PairMessage = serde_json::from_str(
r#"{"type":"pending","id":"i","code":"ABCD-EFGH","expiresIn":600}"#,
)
.unwrap();
assert_eq!(
pending,
PairMessage::Pending {
id: "i".into(),
code: "ABCD-EFGH".into(),
expires_in: 600
}
);
let approved = PairMessage::Approved {
username: "alice".into(),
api_key: "k".into(),
};
assert_eq!(
serde_json::to_string(&approved).unwrap(),
r#"{"type":"approved","username":"alice","apiKey":"k"}"#
);
assert_eq!(
serde_json::to_string(&PairMessage::Expired).unwrap(),
r#"{"type":"expired"}"#
);
}
}