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