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.clone();
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("there is no account called {0}")]
211 NoSuchUser(String),
212 #[error("{0}'s password is not recoverable; invite with a new password instead")]
213 NotRecoverable(String),
214 #[error("the last admin cannot be removed or demoted")]
215 LastAdmin,
216 #[error(transparent)]
217 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
218}
219
220fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
221 AccountError::Other(Box::new(e))
222}
223
224fn seal(
225 conn: &Connection,
226 key: &[u8; 32],
227 username: &str,
228 password: &str,
229) -> Result<(), AccountError> {
230 let sealed = auth::seal_password(key, username, password).map_err(other)?;
231 users::set_sealed_password(conn, username, &sealed).map_err(other)
232}
233
234pub fn create_account(
238 conn: &Connection,
239 key: &[u8; 32],
240 username: &str,
241 role: Role,
242) -> Result<String, AccountError> {
243 let username = username.trim();
244 if username.is_empty()
245 || username.chars().count() > MAX_USERNAME
246 || username.chars().any(char::is_whitespace)
247 {
248 return Err(AccountError::BadUsername);
249 }
250 if users::get_user_by_username(conn, username)
251 .map_err(other)?
252 .is_some()
253 {
254 return Err(AccountError::Taken(username.to_owned()));
255 }
256 let password = generate_password().map_err(other)?;
257 users::create_user(conn, username, &password, role).map_err(other)?;
258 seal(conn, key, username, &password)?;
259 Ok(password)
260}
261
262pub fn account_password(
268 conn: &Connection,
269 key: &[u8; 32],
270 username: &str,
271 reset: bool,
272) -> Result<String, AccountError> {
273 if users::get_user_by_username(conn, username)
274 .map_err(other)?
275 .is_none()
276 {
277 return Err(AccountError::NoSuchUser(username.to_owned()));
278 }
279 if !reset {
280 return users::sealed_password(conn, username)
281 .map_err(other)?
282 .and_then(|sealed| auth::open_password(key, username, &sealed))
283 .ok_or_else(|| AccountError::NotRecoverable(username.to_owned()));
284 }
285 let password = generate_password().map_err(other)?;
286 users::update_password(conn, username, &password)
287 .map_err(|e| AccountError::Other(e.to_string().into()))?;
288 seal(conn, key, username, &password)?;
289 Ok(password)
290}
291
292pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
294 let user = users::get_user_by_username(conn, username)
295 .map_err(other)?
296 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
297 if user.role == Role::Admin
298 && role != Role::Admin
299 && users::admin_count(conn).map_err(other)? <= 1
300 {
301 return Err(AccountError::LastAdmin);
302 }
303 users::update_role(conn, username, role).map_err(other)?;
304 Ok(())
305}
306
307pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
309 let user = users::get_user_by_username(conn, username)
310 .map_err(other)?
311 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
312 if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
313 return Err(AccountError::LastAdmin);
314 }
315 users::delete_user(conn, user.id).map_err(other)?;
316 Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn invite() -> Invite {
324 Invite::new("https://music.example.com/", "sarita", "p&ss word=#?")
325 }
326
327 #[test]
328 fn both_links_round_trip() {
329 let i = invite();
330 assert_eq!(i.server, "https://music.example.com");
331 assert!(i.link().starts_with("https://koan.rocks/join/#server="));
332 assert_eq!(Invite::parse(&i.link()), Some(i.clone()));
333 assert_eq!(Invite::parse(&i.app_link()), Some(i));
334 }
335
336 #[test]
337 fn the_join_page_without_its_slash_still_parses() {
338 let link = invite().link().replacen("/join/#", "/join#", 1);
339 assert_eq!(Invite::parse(&link), Some(invite()));
340 }
341
342 #[test]
343 fn an_address_with_the_account_in_it_is_split() {
344 assert_eq!(
345 Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
346 Some(Invite::new("https://koan.example.com", "sarita", "p@ss+w"))
347 );
348 assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
349 }
350
351 #[test]
352 fn other_links_and_missing_fields_are_refused() {
353 assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
354 assert_eq!(
355 Invite::parse("https://koan.rocks/#server=https://a&username=u&password=p"),
356 None
357 );
358 assert_eq!(
359 Invite::parse("koan://join?server=https://a&username=u"),
360 None
361 );
362 assert_eq!(
363 Invite::parse("koan://join?server=ftp://a&username=u&password=p"),
364 None
365 );
366 assert_eq!(Invite::parse("not a url"), None);
367 }
368
369 #[test]
370 fn credentials_stay_in_the_fragment() {
371 let url = Url::parse(&invite().link()).unwrap();
372 assert_eq!(url.query(), None);
373 assert!(!url.path().contains("sarita"));
374 }
375
376 #[test]
377 fn the_email_carries_the_link_and_the_details() {
378 let i = invite();
379 let text = i.email_text();
380 assert!(text.contains(&i.link()));
381 assert!(text.contains("Server URL: https://music.example.com"));
382 assert!(text.contains("Password: p&ss word=#?"));
383 assert!(i.email_html().contains("p&ss word=#?"));
384 assert!(!i.mailto().contains(' '));
385 }
386
387 #[test]
388 fn generated_passwords_are_typeable() {
389 let p = generate_password().unwrap();
390 assert_eq!(p.len(), 20);
391 assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
392 assert_ne!(p, generate_password().unwrap());
393 }
394
395 #[test]
396 fn accounts_are_created_recovered_and_guarded() {
397 let dir = tempfile::tempdir().unwrap();
398 let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
399 let conn = &db.conn;
400 let key = &[7; 32];
401 let admin = create_account(conn, key, "owner", Role::Admin).unwrap();
402 assert_eq!(account_password(conn, key, "owner", false).unwrap(), admin);
403 assert!(matches!(
404 create_account(conn, key, "owner", Role::User),
405 Err(AccountError::Taken(_))
406 ));
407 assert!(matches!(
408 create_account(conn, key, "two words", Role::User),
409 Err(AccountError::BadUsername)
410 ));
411 assert!(matches!(
412 set_role(conn, "owner", Role::User),
413 Err(AccountError::LastAdmin)
414 ));
415 assert!(matches!(
416 delete_account(conn, "owner"),
417 Err(AccountError::LastAdmin)
418 ));
419
420 let first = create_account(conn, key, "sarita", Role::Readonly).unwrap();
421 let reset = account_password(conn, key, "sarita", true).unwrap();
422 assert_ne!(first, reset);
423 assert_eq!(account_password(conn, key, "sarita", false).unwrap(), reset);
424 delete_account(conn, "sarita").unwrap();
425 assert!(matches!(
426 account_password(conn, key, "sarita", false),
427 Err(AccountError::NoSuchUser(_))
428 ));
429 }
430}