1use crate::*;
2use axum::extract::{FromRequest, Multipart};
3use rusqlite::{OptionalExtension, params as sql};
4
5fn uuid(id: &str) -> Result<()> {
6 if regex::Regex::new(
7 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
8 )
9 .unwrap()
10 .is_match(id)
11 {
12 Ok(())
13 } else {
14 Err(Error::new(400, "No user with that id"))
15 }
16}
17fn profile(body: &Value, full: bool) -> Result<Value> {
18 let mut profile = serde_json::Map::new();
19 let username_pattern = regex::Regex::new(r"^[a-z0-9][a-z0-9._@-]*$").unwrap();
20 for key in [
21 "username",
22 "email",
23 "firstName",
24 "lastName",
25 "enabled",
26 "emailVerified",
27 "requiredActions",
28 ] {
29 let Some(value) = body.get(key) else {
30 if full && ["username", "email", "firstName", "lastName"].contains(&key) {
31 return Err(Error::new(400, "Enter a user profile."));
32 }
33 continue;
34 };
35 let value = match key {
36 "username" => {
37 let v = string(value).trim().to_lowercase();
38 if v.starts_with("ai-") {
39 return Err(Error::new(400, "Usernames beginning with ai- are reserved for model accounts."));
40 }
41 if v.len() > 254 || !username_pattern.is_match(&v) {
42 return Err(Error::new(
43 400,
44 "Usernames use lowercase letters, digits, dots, dashes, and @",
45 ));
46 }
47 json!(v)
48 }
49 "email" | "firstName" | "lastName" => {
50 let v = value
51 .as_str()
52 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?
53 .trim();
54 if v.len() > 254 {
55 return Err(Error::new(400, "Use a shorter name or email address."));
56 }
57 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {
58 return Err(Error::new(400, "Enter a full email address"));
59 }
60 if v.is_empty() { Value::Null } else { json!(v) }
61 }
62 "enabled" | "emailVerified" => {
63 if !value.is_boolean() {
64 return Err(Error::new(400, "Invalid profile."));
65 }
66 value.clone()
67 }
68 _ => {
69 if !value.is_array() || array(value).iter().any(|v| v != "UPDATE_PASSWORD") {
70 return Err(Error::new(400, "Invalid required actions."));
71 }
72 value.clone()
73 }
74 };
75 profile.insert(key.into(), value);
76 }
77 Ok(Value::Object(profile))
78}
79fn password(value: &Value) -> Result<&str> {
80 value
81 .as_str()
82 .filter(|s| s.chars().count() >= 8 && s.len() <= 1024)
83 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))
84}
85
86pub(crate) fn revoke_connections(app: &App, id: &str) -> Result<()> {
87 let mut db = app.mcp.db.lock().unwrap();
88 let transaction = db.transaction()?;
89 for grant in mcp::list(&transaction, "grant:")? {
90 if grant["user"] == id {
91 mcp::revoke(&transaction, string(&grant["id"]))?;
92 }
93 }
94 transaction.execute(
95 "DELETE FROM records WHERE json_extract(value,'$.owner')=?",
96 [id],
97 )?;
98 transaction.commit()?;
99 Ok(())
100}
101
102pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
103 let db = app.auth.db.lock().unwrap();
104 let id: Option<String> = db
105 .query_row(
106 "SELECT id FROM users WHERE username=?",
107 [string(&me["name"])],
108 |r| r.get(0),
109 )
110 .optional()?;
111 auth::user(
112 &db,
113 &id.ok_or_else(|| Error::new(401, "Sign in again to open your account."))?,
114 )
115}
116
117pub async fn route(
118 app: Arc<App>,
119 method: &Method,
120 parts: &[&str],
121 me: &Value,
122 body: Value,
123) -> Result<Response> {
124 if parts == ["groups"] && method == Method::PUT {
125 let users: Vec<String> = serde_json::from_value(body["users"].clone())
126 .map_err(|_| Error::new(400, "Select users to edit."))?;
127 let add: Vec<String> = serde_json::from_value(body["add"].clone())
128 .map_err(|_| Error::new(400, "Choose groups to add."))?;
129 let remove: Vec<String> = serde_json::from_value(body["remove"].clone())
130 .map_err(|_| Error::new(400, "Choose groups to remove."))?;
131 if users.is_empty() || (add.is_empty() && remove.is_empty()) {
132 return Err(Error::new(
133 400,
134 "Select users and change at least one group.",
135 ));
136 }
137 let mut db = app.auth.db.lock().unwrap();
138 let transaction = db.transaction()?;
139 for group in add.iter().chain(&remove) {
140 if add.contains(group) && remove.contains(group) {
141 return Err(Error::new(
142 400,
143 "Choose whether to add or remove each group.",
144 ));
145 }
146 let exists: bool = transaction.query_row(
147 "SELECT EXISTS(SELECT 1 FROM roles WHERE id=?)",
148 [group],
149 |r| r.get(0),
150 )?;
151 if !exists {
152 return Err(Error::new(
153 400,
154 "This group no longer exists. Reload the page.",
155 ));
156 }
157 }
158 for id in &users {
159 uuid(id)?;
160 let user = auth::user(&transaction, id)?;
161 if guest::is_guest(&user) || user["kind"] == "ai" {
162 return Err(Error::new(
163 400,
164 "Guests can use Shale only. Select regular accounts to edit groups.",
165 ));
166 }
167 for group in &add {
168 transaction.execute(
169 "INSERT OR IGNORE INTO memberships VALUES (?,?)",
170 sql![id, group],
171 )?;
172 }
173 for group in &remove {
174 if user["username"] == me["name"]
175 && array(&user["groups"])
176 .iter()
177 .any(|g| g["id"] == *group && g["name"] == "infra-admin")
178 {
179 return Err(Error::new(
180 400,
181 "Sign in as another admin to remove your admin access.",
182 ));
183 }
184 transaction.execute(
185 "DELETE FROM memberships WHERE user_id=? AND role_id=?",
186 sql![id, group],
187 )?;
188 }
189 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?", [id])?;
190 }
191 transaction.commit()?;
192 drop(db);
193 if !remove.is_empty() {
194 for id in users {
195 revoke_connections(&app, &id)?;
196 }
197 }
198 return Ok(StatusCode::NO_CONTENT.into_response());
199 }
200 if parts.is_empty() && method == Method::GET {
201 let db = app.auth.db.lock().unwrap();
202 let mut statement = db.prepare("SELECT id FROM users WHERE coalesce(json_extract(profile,'$.kind'),'')!='ai' ORDER BY username")?;
203 let ids = statement
204 .query_map([], |r| r.get::<_, String>(0))?
205 .collect::<std::result::Result<Vec<_>, _>>()?;
206 let users = ids
207 .iter()
208 .map(|id| {
209 let mut user = auth::user(&db, id)?;
210 user["sessions"] = auth::Store::sessions(&db, id)?;
211 Ok(user)
212 })
213 .collect::<Result<Vec<_>>>()?;
214 let mut statement = db.prepare("SELECT id,name FROM roles ORDER BY name")?;
215 let groups = statement
216 .query_map([], |r| {
217 Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?}))
218 })?
219 .collect::<std::result::Result<Vec<_>, _>>()?;
220 return Ok(Document::new(json!({"users":users,"groups":groups})).response());
221 }
222 if parts.is_empty() && method == Method::POST {
223 let mut profile = profile(&body["profile"], true)?;
224 let setup = string(&body["setup"]["kind"]);
225 if setup != "invite" && setup != "password" {
226 return Err(Error::new(400, "Choose an invitation or a password."));
227 }
228 let hash = if setup == "password" {
229 Some(
230 app.auth
231 .hash_password(password(&body["setup"]["password"])?)
232 .await?,
233 )
234 } else {
235 None
236 };
237 if !body["groups"].is_array() {
238 return Err(Error::new(400, "Choose groups."));
239 }
240 let id = uuid::Uuid::new_v4().to_string();
241 profile["enabled"] = json!(true);
242 profile["emailVerified"] = json!(false);
243 profile["requiredActions"] = json!([if hash.is_some() {
244 "UPDATE_PASSWORD"
245 } else {
246 "SETUP"
247 }]);
248 profile["createdTimestamp"] = json!((now() * 1000.0) as i64);
249 profile["attributes"] = json!({});
250 {
251 let mut db = app.auth.db.lock().unwrap();
252 let transaction = db.transaction()?;
253 transaction
254 .execute(
255 "INSERT INTO users(id,profile) VALUES (?,?)",
256 sql![id, profile.to_string()],
257 )
258 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
259 for group in array(&body["groups"]) {
260 let group = string(group);
261 if transaction.execute(
262 "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?",
263 sql![id, group],
264 )? == 0
265 {
266 return Err(Error::new(400, "Choose an available group."));
267 }
268 }
269 if let Some(hash) = &hash {
270 auth::set_password(&transaction, &id, hash)?;
271 }
272 transaction.commit()?;
273 }
274 return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response());
275 }
276 let id = parts
277 .first()
278 .ok_or_else(|| Error::new(404, "No user here."))?;
279 uuid(id)?;
280 if auth::user(&app.auth.db.lock().unwrap(), id)?["kind"] == "ai" {
281 return Err(Error::new(404, "No user here."));
282 }
283 if *parts == [*id, "setup-link"] && method == Method::POST {
284 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
285 }
286 let hash = if *parts == [*id, "password"] && method == Method::PUT {
287 Some(app.auth.hash_password(password(&body["password"])?).await?)
288 } else {
289 None
290 };
291 let mut db = app.auth.db.lock().unwrap();
292 let transaction = db.transaction()?;
293 let mut user = auth::user(&transaction, id)?;
294 if guest::is_guest(&user)
295 && matches!(parts, [_, "password"] | [_, "groups", _])
296 && method != Method::GET
297 {
298 return Err(Error::new(
299 400,
300 "Guests can use Shale only. Invite a separate account for other services.",
301 ));
302 }
303 let own = user["username"] == me["name"];
304 let value = match parts {
305 [_] if method == Method::PATCH => {
306 let patch = profile(&body, false)?;
307 if own && patch["enabled"] == false {
308 return Err(Error::new(
309 400,
310 "Sign in as another admin to disable your account.",
311 ));
312 }
313 if patch.get("username").is_some() && patch["username"] != user["username"] {
314 return Err(Error::new(
315 400,
316 "Usernames are fixed to preserve service identities.",
317 ));
318 }
319 user.as_object_mut()
320 .unwrap()
321 .extend(patch.as_object().unwrap().clone());
322 auth::save_user(&transaction, id, user)?;
323 Value::Null
324 }
325 [_] if method == Method::DELETE => {
326 if own {
327 return Err(Error::new(
328 400,
329 "Sign in as another admin to delete your account.",
330 ));
331 }
332 transaction.execute(
333 "DELETE FROM pending WHERE json_extract(data,'$.user')=?",
334 [id],
335 )?;
336 transaction.execute("DELETE FROM users WHERE id=?", [id])?;
337 Value::Null
338 }
339 [_, "groups", group] if method == Method::PUT || method == Method::DELETE => {
340 let name: Option<String> = transaction
341 .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0))
342 .optional()?;
343 let name = name
344 .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?;
345 if own && method == Method::DELETE && name == "infra-admin" {
346 return Err(Error::new(
347 400,
348 "Sign in as another admin to remove your admin access.",
349 ));
350 }
351 if method == Method::PUT {
352 transaction.execute(
353 "INSERT OR IGNORE INTO memberships VALUES (?,?)",
354 sql![id, group],
355 )?;
356 } else {
357 transaction.execute(
358 "DELETE FROM memberships WHERE user_id=? AND role_id=?",
359 sql![id, group],
360 )?;
361 }
362 Value::Null
363 }
364 [_, "commit-identity"] if method == Method::GET => identity::account(&transaction, id)?,
365 [_, "commit-emails", email] if method == Method::PUT => {
366 let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
367 let exists: bool = transaction.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
368 if !exists { return Err(Error::new(404, "This account has not added that email.")); }
369 identity::add_email(&transaction,id,&json!(email),true)?;
370 Value::Null
371 }
372 [_, "signing-keys", fingerprint] if method == Method::PUT => {
373 let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
374 identity::confirm_key(&transaction,id,&fingerprint)?;
375 Value::Null
376 }
377 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
378 [_, "logout"] if method == Method::POST => {
379 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
380 Value::Null
381 }
382 [_, "setup-link"] if method == Method::DELETE => {
383 transaction.execute(
384 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
385 [id],
386 )?;
387 Value::Null
388 }
389 [_, "password"] if method == Method::PUT => {
390 if !body["temporary"].is_boolean() {
391 return Err(Error::new(
392 400,
393 "Choose whether this password is temporary.",
394 ));
395 }
396 auth::set_password(&transaction, id, hash.as_deref().unwrap())?;
397 user["requiredActions"] = if body["temporary"] == true {
398 json!(["UPDATE_PASSWORD"])
399 } else {
400 json!([])
401 };
402 auth::save_user(&transaction, id, user)?;
403 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
404 Value::Null
405 }
406 _ => return Err(Error::new(404, "No account action here.")),
407 };
408 if method != Method::GET {
409 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?",[id])?;
410 if body["enabled"] == false {
411 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
412 transaction.execute(
413 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
414 [id],
415 )?;
416 }
417 }
418 transaction.commit()?;
419 drop(db);
420 if body["enabled"] == false
421 || hash.is_some()
422 || (method == Method::DELETE && !matches!(parts, [_, "setup-link"]))
423 || matches!(parts, [_, "logout"])
424 {
425 revoke_connections(&app, id)?;
426 }
427 Ok(if value.is_null() {
428 StatusCode::NO_CONTENT.into_response()
429 } else {
430 Document::new(value).response()
431 })
432}
433fn image_type(bytes: &[u8]) -> Option<&'static str> {
434 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
435 Some("image/webp")
436 } else if bytes.get(1..4) == Some(b"PNG") {
437 Some("image/png")
438 } else {
439 None
440 }
441}
442pub async fn picture(app: &App, parts: &[&str]) -> Result<Response> {
443 let ["account", "pictures", id] = parts else {
444 return Err(Error::new(404, "No picture here"));
445 };
446 if id.is_empty()
447 || !id
448 .bytes()
449 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
450 {
451 return Err(Error::new(404, "No picture here"));
452 }
453 let bytes = tokio::fs::read(app.data.join("pictures").join(id))
454 .await
455 .map_err(|_| Error::new(404, "No picture here"))?;
456 Ok((
457 [
458 (
459 "content-type",
460 image_type(&bytes).unwrap_or("application/octet-stream"),
461 ),
462 ("cache-control", "max-age=86400"),
463 ],
464 bytes,
465 )
466 .into_response())
467}
468pub async fn account(
469 app: Arc<App>,
470 request: Request,
471 parts: &[&str],
472 me: &Value,
473) -> Result<Response> {
474 let method = request.method().clone();
475 let mut user = self_user(&app, me).await?;
476 let id = string(&user["id"]).to_owned();
477 let value = match parts {
478 [] if method == Method::GET => {
479 user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?;
480 user["picture"] = user["attributes"]["picture"][0].clone();
481 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
482 user.as_object_mut().unwrap().remove("attributes");
483 user
484 }
485 [] if method == Method::PATCH => {
486 let body: Value =
487 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
488 for key in ["firstName", "lastName", "email"] {
489 if body.get(key).is_some() {
490 let mut field = serde_json::Map::new();
491 field.insert(key.to_owned(), body[key].clone());
492 let patch = profile(&Value::Object(field), false)?;
493 user[key] = patch[key].clone();
494 if key == "email" {
495 user["emailVerified"] = json!(false);
496 }
497 }
498 }
499 user["requiredActions"] = json!(
500 array(&user["requiredActions"])
501 .iter()
502 .filter(|v| **v != "UPDATE_PROFILE")
503 .collect::<Vec<_>>()
504 );
505 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
506 Value::Null
507 }
508 ["commit-emails"] if method == Method::POST => {
509 app.auth.recent(request.headers())?;
510 let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
511 let mut db = app.auth.db.lock().unwrap();
512 let tx = db.transaction()?;
513 identity::add_email(&tx, &id, &body["email"], false)?;
514 tx.commit()?;
515 Value::Null
516 }
517 ["commit-emails", email] if method == Method::PUT || method == Method::DELETE => {
518 app.auth.recent(request.headers())?;
519 let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
520 let mut db = app.auth.db.lock().unwrap();
521 let tx = db.transaction()?;
522 if method == Method::PUT {
523 if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
524 return Err(Error::new(403, "Ask an admin to confirm ownership of this email."));
525 }
526 let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
527 if !exists { return Err(Error::new(404, "Add this email before confirming ownership.")); }
528 identity::add_email(&tx, &id, &json!(email), true)?;
529 } else {
530 tx.execute("DELETE FROM commit_emails WHERE user_id=? AND email=?",sql![id,email])?;
531 }
532 tx.commit()?;
533 Value::Null
534 }
535 ["signing-keys"] if method == Method::POST => {
536 app.auth.recent(request.headers())?;
537 let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 32768).await?)?;
538 let mut db = app.auth.db.lock().unwrap();
539 let tx = db.transaction()?;
540 identity::add_key(&tx, &id, &body, false)?;
541 tx.commit()?;
542 Value::Null
543 }
544 ["signing-keys", fingerprint] if method == Method::PUT || method == Method::DELETE => {
545 let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
546 app.auth.recent(request.headers())?;
547 let mut db = app.auth.db.lock().unwrap();
548 let tx = db.transaction()?;
549 if method == Method::PUT {
550 if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
551 return Err(Error::new(403, "Ask an admin to confirm ownership of this signing key."));
552 }
553 identity::confirm_key(&tx,&id,&fingerprint)?;
554 } else { tx.execute("DELETE FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint.as_ref()])?; }
555 tx.commit()?;
556 Value::Null
557 }
558 ["credentials", credential] if method == Method::DELETE => {
559 app.auth.recent(request.headers())?;
560 let mut db = app.auth.db.lock().unwrap();
561 let transaction = db.transaction()?;
562 let count: i64 = transaction.query_row(
563 "SELECT count(*) FROM credentials WHERE user_id=?",
564 [&id],
565 |r| r.get(0),
566 )?;
567 if count <= 1 {
568 return Err(Error::new(
569 400,
570 "Add another sign-in method before removing this one.",
571 ));
572 }
573 if transaction.execute(
574 "DELETE FROM credentials WHERE user_id=? AND id=?",
575 sql![id, credential],
576 )? == 0
577 {
578 return Err(Error::new(404, "This sign-in method was already removed."));
579 }
580 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration') AND json_extract(data,'$.user')=?",[&id])?;
581 transaction.commit()?;
582 Value::Null
583 }
584 ["picture"] if method == Method::PUT => {
585 let (parts, body) = request.into_parts();
586 let bytes = axum::body::to_bytes(body, 512 * 1024)
587 .await
588 .map_err(|_| Error::new(413, "That picture is over 512 KB. Pick a smaller one."))?;
589 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
590 let mut multipart = Multipart::from_request(request, &())
591 .await
592 .map_err(|_| Error::new(400, "Pick a picture to upload"))?;
593 let mut picture = None;
594 while let Some(field) = multipart
595 .next_field()
596 .await
597 .map_err(|_| Error::new(400, "Pick a picture to upload"))?
598 {
599 if field.name() == Some("picture") && field.file_name().is_some() {
600 picture = Some(
601 field
602 .bytes()
603 .await
604 .map_err(|_| Error::new(400, "Pick a picture to upload"))?,
605 );
606 break;
607 }
608 }
609 let bytes = picture.ok_or_else(|| Error::new(400, "Pick a picture to upload"))?;
610 if image_type(&bytes).is_none() {
611 return Err(Error::new(415, "Upload a WebP or PNG picture"));
612 }
613 tokio::fs::create_dir_all(app.data.join("pictures")).await?;
614 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;
615 let picture = format!(
616 "{}/api/account/pictures/{id}?v={}",
617 app.auth.origin.origin().ascii_serialization(),
618 (now() * 1000.0) as u64
619 );
620 user["attributes"]["picture"] = json!([picture]);
621 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
622 json!({"picture":picture})
623 }
624 ["picture"] if method == Method::DELETE => {
625 user["attributes"]["picture"] = Value::Null;
626 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
627 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;
628 Value::Null
629 }
630 _ => return Err(Error::new(404, "No account action here.")),
631 };
632 Ok(if value.is_null() {
633 StatusCode::NO_CONTENT.into_response()
634 } else {
635 Document::new(value).response()
636 })
637}