1use rusqlite::Connection;
15use url::{Url, form_urlencoded};
16
17use crate::auth::{self, Role};
18use crate::db::queries::auth as users;
19
20pub const JOIN_PAGE: &str = "https://koan.rocks/join/";
21pub const APP_STORE: &str = "https://apps.apple.com/app/id6817137172";
22pub const MAC_DOWNLOAD: &str = "https://github.com/radiosilence/koan/releases/latest";
23
24const MAX_USERNAME: usize = 64;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Invite {
28 pub server: String,
29 pub username: String,
30 pub password: String,
31}
32
33impl Invite {
34 pub fn new(server: &str, username: &str, password: &str) -> Self {
35 Self {
36 server: server.trim().trim_end_matches('/').to_owned(),
37 username: username.to_owned(),
38 password: password.to_owned(),
39 }
40 }
41
42 fn params(&self) -> String {
43 form_urlencoded::Serializer::new(String::new())
44 .append_pair("server", &self.server)
45 .append_pair("username", &self.username)
46 .append_pair("password", &self.password)
47 .finish()
48 }
49
50 pub fn link(&self) -> String {
52 format!("{JOIN_PAGE}#{}", self.params())
53 }
54
55 pub fn app_link(&self) -> String {
57 format!("koan://join?{}", self.params())
58 }
59
60 pub fn parse(link: &str) -> Option<Self> {
65 let url = Url::parse(link.trim()).ok()?;
66 if matches!(url.scheme(), "http" | "https") && !url.username().is_empty() {
67 let decode = |s: &str| percent_decode(s);
68 let (username, password) = (decode(url.username()), decode(url.password()?));
69 if password.is_empty() {
70 return None;
71 }
72 let mut server = url;
73 server.set_username("").ok()?;
74 server.set_password(None).ok()?;
75 return Some(Self::new(server.as_str(), &username, &password));
76 }
77 let params = match (url.scheme(), url.host_str()) {
78 ("koan", Some("join")) => url.query().or(url.fragment()),
79 ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/join" => {
80 url.fragment()
81 }
82 _ => None,
83 }?;
84 let (mut server, mut username, mut password) = (None, None, None);
85 for (k, v) in form_urlencoded::parse(params.as_bytes()) {
86 match &*k {
87 "server" => server = Some(v.into_owned()),
88 "username" => username = Some(v.into_owned()),
89 "password" => password = Some(v.into_owned()),
90 _ => {}
91 }
92 }
93 let (server, username, password) = (server?, username?, password?);
94 let scheme = Url::parse(&server).ok()?.scheme().to_owned();
95 if !matches!(scheme.as_str(), "http" | "https")
96 || username.is_empty()
97 || password.is_empty()
98 {
99 return None;
100 }
101 Some(Self::new(&server, &username, &password))
102 }
103
104 pub fn email_subject(&self) -> String {
105 "Your koan account".to_owned()
106 }
107
108 pub fn email_text(&self) -> String {
109 format!(
110 "I've made you an account on my music server.\n\
111 \n\
112 1. Install koan: from the App Store on an iPhone or iPad ({APP_STORE}), \
113 or for a Mac from {MAC_DOWNLOAD}\n\
114 2. On that device, open this link:\n\
115 \n\
116 {link}\n\
117 \n\
118 koan signs in and loads the library by itself.\n\
119 \n\
120 Using a different Subsonic app? Sign in with:\n\
121 \n\
122 Server URL: {server}\n\
123 Username: {username}\n\
124 Password: {password}\n",
125 link = self.link(),
126 server = self.server,
127 username = self.username,
128 password = self.password,
129 )
130 }
131
132 pub fn email_html(&self) -> String {
135 let e = html_escape;
136 format!(
137 "<p>I've made you an account on my music server.</p>\
138 <ol><li>Install koan: from the <a href=\"{APP_STORE}\">App Store</a> on an iPhone \
139 or iPad, or <a href=\"{MAC_DOWNLOAD}\">for a Mac</a>.</li>\
140 <li>On that device, open this link:</li></ol>\
141 <p><a href=\"{link}\" style=\"display:inline-block;padding:10px 18px;\
142 border-radius:8px;background:#111;color:#fff;text-decoration:none;\
143 font-weight:600\">Open in koan</a></p>\
144 <p>koan signs in and loads the library by itself.</p>\
145 <p>Using a different Subsonic app? Sign in with:</p>\
146 <p>Server URL: {server}<br>Username: {username}<br>Password: {password}</p>",
147 link = e(&self.link()),
148 server = e(&self.server),
149 username = e(&self.username),
150 password = e(&self.password),
151 )
152 }
153
154 pub fn mailto(&self) -> String {
156 let enc = |s: &str| {
157 form_urlencoded::byte_serialize(s.as_bytes())
158 .collect::<String>()
159 .replace('+', "%20")
160 };
161 format!(
162 "mailto:?subject={}&body={}",
163 enc(&self.email_subject()),
164 enc(&self.email_text())
165 )
166 }
167}
168
169fn percent_decode(s: &str) -> String {
170 form_urlencoded::parse(format!("x={}", s.replace('+', "%2B")).as_bytes())
171 .next()
172 .map(|(_, v)| v.into_owned())
173 .unwrap_or_default()
174}
175
176fn html_escape(s: &str) -> String {
177 s.replace('&', "&")
178 .replace('<', "<")
179 .replace('>', ">")
180 .replace('"', """)
181}
182
183pub fn generate_password() -> Result<String, auth::AuthError> {
186 use ring::rand::SecureRandom;
187
188 const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
189 let rng = ring::rand::SystemRandom::new();
190 let mut out = String::with_capacity(20);
191 let mut byte = [0u8; 1];
192 while out.len() < 20 {
193 rng.fill(&mut byte)
194 .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
195 let limit = 256 - 256 % ALPHABET.len();
197 if (byte[0] as usize) < limit {
198 out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
199 }
200 }
201 Ok(out)
202}
203
204#[derive(Debug, thiserror::Error)]
205pub enum AccountError {
206 #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
207 BadUsername,
208 #[error("there is already an account called {0}")]
209 Taken(String),
210 #[error("{0} is reserved")]
211 Reserved(String),
212 #[error("there is no account called {0}")]
213 NoSuchUser(String),
214 #[error("{0}'s password is not recoverable; invite with a new password instead")]
215 NotRecoverable(String),
216 #[error("the last admin cannot be removed or demoted")]
217 LastAdmin,
218 #[error(transparent)]
219 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
220}
221
222fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
223 AccountError::Other(Box::new(e))
224}
225
226fn seal(
227 conn: &Connection,
228 key: &[u8; 32],
229 username: &str,
230 password: &str,
231) -> Result<(), AccountError> {
232 let sealed = auth::seal_password(key, username, password).map_err(other)?;
233 users::set_sealed_password(conn, username, &sealed).map_err(other)
234}
235
236pub fn create_account(
240 conn: &Connection,
241 key: &[u8; 32],
242 username: &str,
243 role: Role,
244) -> Result<String, AccountError> {
245 let username = username.trim();
246 if username.is_empty()
247 || username.chars().count() > MAX_USERNAME
248 || username.chars().any(char::is_whitespace)
249 {
250 return Err(AccountError::BadUsername);
251 }
252 if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
253 return Err(AccountError::Reserved(username.to_owned()));
254 }
255 if users::get_user_by_username(conn, username)
256 .map_err(other)?
257 .is_some()
258 {
259 return Err(AccountError::Taken(username.to_owned()));
260 }
261 let password = generate_password().map_err(other)?;
262 users::create_user(conn, username, &password, role).map_err(other)?;
263 seal(conn, key, username, &password)?;
264 Ok(password)
265}
266
267pub fn account_password(
274 conn: &Connection,
275 key: &[u8; 32],
276 username: &str,
277 reset: bool,
278) -> Result<String, AccountError> {
279 if users::get_user_by_username(conn, username)
280 .map_err(other)?
281 .is_none()
282 {
283 return Err(AccountError::NoSuchUser(username.to_owned()));
284 }
285 if !reset {
286 return users::sealed_password(conn, username)
287 .map_err(other)?
288 .and_then(|sealed| auth::open_password(key, username, &sealed))
289 .ok_or_else(|| AccountError::NotRecoverable(username.to_owned()));
290 }
291 let password = generate_password().map_err(other)?;
292 users::update_password(conn, username, &password)
293 .map_err(|e| AccountError::Other(e.to_string().into()))?;
294 seal(conn, key, username, &password)?;
295 Ok(password)
296}
297
298pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
300 let user = users::get_user_by_username(conn, username)
301 .map_err(other)?
302 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
303 if user.role == Role::Admin
304 && role != Role::Admin
305 && users::admin_count(conn).map_err(other)? <= 1
306 {
307 return Err(AccountError::LastAdmin);
308 }
309 users::update_role(conn, username, role).map_err(other)?;
310 Ok(())
311}
312
313pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
315 let user = users::get_user_by_username(conn, username)
316 .map_err(other)?
317 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
318 if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
319 return Err(AccountError::LastAdmin);
320 }
321 users::delete_user(conn, user.id).map_err(other)?;
322 Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn invite() -> Invite {
330 Invite::new("https://music.example.com/", "sarita", "p&ss word=#?")
331 }
332
333 #[test]
334 fn both_links_round_trip() {
335 let i = invite();
336 assert_eq!(i.server, "https://music.example.com");
337 assert!(i.link().starts_with("https://koan.rocks/join/#server="));
338 assert_eq!(Invite::parse(&i.link()), Some(i.clone()));
339 assert_eq!(Invite::parse(&i.app_link()), Some(i));
340 }
341
342 #[test]
343 fn the_join_page_without_its_slash_still_parses() {
344 let link = invite().link().replacen("/join/#", "/join#", 1);
345 assert_eq!(Invite::parse(&link), Some(invite()));
346 }
347
348 #[test]
349 fn an_address_with_the_account_in_it_is_split() {
350 assert_eq!(
351 Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
352 Some(Invite::new("https://koan.example.com", "sarita", "p@ss+w"))
353 );
354 assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
355 }
356
357 #[test]
358 fn other_links_and_missing_fields_are_refused() {
359 assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
360 assert_eq!(
361 Invite::parse("https://koan.rocks/#server=https://a&username=u&password=p"),
362 None
363 );
364 assert_eq!(
365 Invite::parse("koan://join?server=https://a&username=u"),
366 None
367 );
368 assert_eq!(
369 Invite::parse("koan://join?server=ftp://a&username=u&password=p"),
370 None
371 );
372 assert_eq!(Invite::parse("not a url"), None);
373 }
374
375 #[test]
376 fn credentials_stay_in_the_fragment() {
377 let url = Url::parse(&invite().link()).unwrap();
378 assert_eq!(url.query(), None);
379 assert!(!url.path().contains("sarita"));
380 }
381
382 #[test]
383 fn the_email_carries_the_link_and_the_details() {
384 let i = invite();
385 let text = i.email_text();
386 assert!(text.contains(&i.link()));
387 assert!(text.contains("Server URL: https://music.example.com"));
388 assert!(text.contains("Password: p&ss word=#?"));
389 assert!(i.email_html().contains("p&ss word=#?"));
390 assert!(!i.mailto().contains(' '));
391 }
392
393 #[test]
394 fn generated_passwords_are_typeable() {
395 let p = generate_password().unwrap();
396 assert_eq!(p.len(), 20);
397 assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
398 assert_ne!(p, generate_password().unwrap());
399 }
400
401 #[test]
402 fn accounts_are_created_recovered_and_guarded() {
403 let dir = tempfile::tempdir().unwrap();
404 let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
405 let conn = &db.conn;
406 let key = &[7; 32];
407 let admin = create_account(conn, key, "owner", Role::Admin).unwrap();
408 assert_eq!(account_password(conn, key, "owner", false).unwrap(), admin);
409 assert!(matches!(
410 create_account(conn, key, "owner", Role::User),
411 Err(AccountError::Taken(_))
412 ));
413 assert!(matches!(
414 create_account(conn, key, "two words", Role::User),
415 Err(AccountError::BadUsername)
416 ));
417 assert!(matches!(
418 create_account(conn, key, "anonymous", Role::User),
419 Err(AccountError::Reserved(_))
420 ));
421 assert!(matches!(
422 set_role(conn, "owner", Role::User),
423 Err(AccountError::LastAdmin)
424 ));
425 assert!(matches!(
426 delete_account(conn, "owner"),
427 Err(AccountError::LastAdmin)
428 ));
429
430 let first = create_account(conn, key, "sarita", Role::Readonly).unwrap();
431 let sarita = users::get_user_by_username(conn, "sarita")
432 .unwrap()
433 .unwrap()
434 .id;
435 let (_, api_key) =
436 crate::db::queries::api_keys::create_api_key(conn, sarita, "phone").unwrap();
437 account_password(conn, key, "sarita", false).unwrap();
439 assert!(
440 crate::db::queries::api_keys::authenticate_api_key(conn, &api_key)
441 .unwrap()
442 .is_some()
443 );
444 let reset = account_password(conn, key, "sarita", true).unwrap();
445 assert_ne!(first, reset);
446 assert_eq!(account_password(conn, key, "sarita", false).unwrap(), reset);
447 assert!(
449 crate::db::queries::api_keys::authenticate_api_key(conn, &api_key)
450 .unwrap()
451 .is_none()
452 );
453 delete_account(conn, "sarita").unwrap();
454 assert!(matches!(
455 account_password(conn, key, "sarita", false),
456 Err(AccountError::NoSuchUser(_))
457 ));
458 }
459}