sl_map_web/routes/users.rs
1//! User-profile endpoints. The profile is a public-within-the-app view of
2//! a user (`user_id`, `username`, `legacy_name`, `created_at`) that any
3//! authenticated caller can read; the self-only `DELETE /api/users/me`
4//! lets a user remove their own account.
5//!
6//! Audit columns elsewhere (`groups.created_by`, `saved_notecards.uploaded_by`,
7//! `saved_renders.created_by`) are `ON DELETE SET NULL` after migration
8//! 0008, so deleting a user anonymises their historical contributions
9//! instead of cascading data destruction. The handler still refuses if
10//! the caller is the sole owner of any group, because that would leave
11//! the group with zero owners — a state the application's invariants
12//! do not allow.
13
14use axum::Json;
15use axum::extract::{Path, State};
16use axum::http::StatusCode as ReqwestStatusCode;
17use axum::response::{IntoResponse as _, Response};
18use axum_extra::extract::cookie::SignedCookieJar;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::auth::{self, CurrentUser, removal_cookie};
24use crate::error::Error;
25use crate::state::AppState;
26
27/// True if `s` is canonical `#rrggbb` — exactly one leading `#` and six
28/// ASCII hex digits. Used to validate the route-colour preference on
29/// the way in.
30fn is_canonical_hex_color(s: &str) -> bool {
31 let mut chars = s.chars();
32 if chars.next() != Some('#') {
33 return false;
34 }
35 let mut count = 0_usize;
36 for c in chars {
37 if !c.is_ascii_hexdigit() {
38 return false;
39 }
40 count = count.saturating_add(1);
41 }
42 count == 6
43}
44
45/// Public view of a registered user, suitable for the profile page and
46/// for the creator/uploader link-throughs in the library and groups
47/// pages.
48#[derive(Debug, Clone, Serialize)]
49pub struct UserProfile {
50 /// the SL avatar UUID.
51 pub user_id: Uuid,
52 /// `firstname.lastname` (the login username).
53 pub username: String,
54 /// `Firstname Lastname` (the legacy display name).
55 pub legacy_name: String,
56 /// when the account was first registered.
57 pub created_at: DateTime<Utc>,
58}
59
60/// `GET /api/users/{user_id}` — return the public profile of a user.
61///
62/// Any authenticated caller may read any registered user's profile —
63/// the columns returned (username + legacy name) are already exposed via
64/// every notecard / render / group-membership row the user has touched,
65/// so making them queryable by UUID is not an additional leak. The
66/// alternative — only letting users you share a group with see your
67/// profile — would force separate code paths in the library / groups
68/// listings just to render the same names already visible there.
69///
70/// # Errors
71///
72/// Returns [`Error::NotFound`] if the user does not exist.
73pub async fn get(
74 _user: CurrentUser,
75 State(state): State<AppState>,
76 Path(user_id): Path<Uuid>,
77) -> Result<Json<UserProfile>, Error> {
78 let row: Option<(String, String, DateTime<Utc>)> =
79 sqlx::query_as("SELECT username, legacy_name, created_at FROM users WHERE user_id = ?1")
80 .bind(user_id.as_bytes().to_vec())
81 .fetch_optional(&state.db)
82 .await
83 .map_err(|err| {
84 tracing::error!("user profile lookup failed: {err}");
85 Error::Database
86 })?;
87 let (username, legacy_name, created_at) =
88 row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))?;
89 Ok(Json(UserProfile {
90 user_id,
91 username,
92 legacy_name,
93 created_at,
94 }))
95}
96
97/// `DELETE /api/users/me` — delete the calling user's account.
98///
99/// Refuses with [`Error::BadRequest`] if the caller is the sole owner
100/// of any group, listing the offending groups so the user knows what
101/// to fix. Otherwise the user row is removed in a single statement;
102/// FK cascades clean up sessions, memberships, invitations, rate-bucket
103/// state, and personal-scope notecards / renders. Group-scope uploads
104/// the user contributed survive: the audit FKs are `ON DELETE SET NULL`
105/// so the rows stay but no longer name the deleted user.
106///
107/// The session cookie is cleared in the response so the browser does
108/// not keep sending a now-invalid id (the session row itself is gone
109/// via cascade, but the cookie clear avoids one extra round-trip
110/// before the user is bounced back to /login).
111///
112/// # Errors
113///
114/// Returns [`Error::BadRequest`] for sole-ownership; [`Error::Database`]
115/// on any underlying query failure.
116pub async fn delete_me(
117 user: CurrentUser,
118 State(state): State<AppState>,
119 jar: SignedCookieJar,
120) -> Result<(SignedCookieJar, Response), Error> {
121 // Sole-owner check: every group where the caller is an `owner` and
122 // is the only `owner` blocks deletion. A multi-owner group is fine
123 // because the user's own membership cascades and the remaining
124 // owner(s) keep the group whole.
125 let sole_owner_groups: Vec<(Vec<u8>, String)> = sqlx::query_as(
126 "SELECT g.group_id, g.name \
127 FROM \"groups\" AS g \
128 JOIN group_memberships AS gm \
129 ON gm.group_id = g.group_id \
130 WHERE gm.user_id = ?1 \
131 AND gm.role = 'owner' \
132 AND (SELECT COUNT(*) FROM group_memberships \
133 WHERE group_id = g.group_id AND role = 'owner') = 1",
134 )
135 .bind(user.user_id.as_bytes().to_vec())
136 .fetch_all(&state.db)
137 .await
138 .map_err(|err| {
139 tracing::error!("sole-owner precheck failed: {err}");
140 Error::Database
141 })?;
142 if !sole_owner_groups.is_empty() {
143 let names: Vec<String> = sole_owner_groups.into_iter().map(|(_, n)| n).collect();
144 return Err(Error::BadRequest(format!(
145 "you are the sole owner of the following group(s): {}. \
146 Promote another member to owner or delete the group(s) first.",
147 names.join(", ")
148 )));
149 }
150
151 let result = sqlx::query("DELETE FROM users WHERE user_id = ?1")
152 .bind(user.user_id.as_bytes().to_vec())
153 .execute(&state.db)
154 .await
155 .map_err(|err| {
156 tracing::error!("delete user failed: {err}");
157 Error::Database
158 })?;
159 if result.rows_affected() == 0 {
160 // The row vanished out from under us between the precheck and
161 // here — treat as already-deleted from the caller's POV.
162 tracing::warn!("delete_me: user row was already gone");
163 }
164 // Best-effort session-id removal (the cascade already nuked the row,
165 // but `delete_session` is a no-op on a missing id, so it is cheap
166 // to call defensively).
167 if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
168 drop(auth::delete_session(&state.db, &session_id).await);
169 }
170 let cleared = jar.add(removal_cookie(&state.config));
171 Ok((cleared, (ReqwestStatusCode::NO_CONTENT, "").into_response()))
172}
173
174/// JSON body for `PATCH /api/users/me/preferences`. A `null`
175/// `route_color` clears the preference (back to the picker default).
176#[derive(Debug, Deserialize)]
177pub struct UpdatePreferences {
178 /// new route colour as canonical `#rrggbb`, or `None` to clear.
179 pub route_color: Option<String>,
180}
181
182/// `PATCH /api/users/me/preferences` — update the calling user's
183/// preferences. Currently the only preference is `route_color`, the
184/// default for the renderer page's route-colour picker.
185///
186/// # Errors
187///
188/// Returns [`Error::BadRequest`] if `route_color` is supplied but does
189/// not match the canonical `#rrggbb` format; [`Error::Database`] on
190/// underlying query failure.
191pub async fn update_preferences(
192 user: CurrentUser,
193 State(state): State<AppState>,
194 Json(req): Json<UpdatePreferences>,
195) -> Result<Response, Error> {
196 if let Some(ref c) = req.route_color
197 && !is_canonical_hex_color(c)
198 {
199 return Err(Error::BadRequest(format!(
200 "route_color must be canonical `#rrggbb`, got {c:?}"
201 )));
202 }
203 sqlx::query("UPDATE users SET route_color = ?1 WHERE user_id = ?2")
204 .bind(&req.route_color)
205 .bind(user.user_id.as_bytes().to_vec())
206 .execute(&state.db)
207 .await
208 .map_err(|err| {
209 tracing::error!("update preferences failed: {err}");
210 Error::Database
211 })?;
212 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
213}