Skip to main content

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
27use crate::library::is_canonical_hex_color;
28
29/// Public view of a registered user, suitable for the profile page and
30/// for the creator/uploader link-throughs in the library and groups
31/// pages.
32#[derive(Debug, Clone, Serialize)]
33pub struct UserProfile {
34    /// the SL avatar UUID.
35    pub user_id: Uuid,
36    /// `firstname.lastname` (the login username).
37    pub username: String,
38    /// `Firstname Lastname` (the legacy display name).
39    pub legacy_name: String,
40    /// when the account was first registered.
41    pub created_at: DateTime<Utc>,
42}
43
44/// `GET /api/users/{user_id}` — return the public profile of a user.
45///
46/// Any authenticated caller may read any registered user's profile —
47/// the columns returned (username + legacy name) are already exposed via
48/// every notecard / render / group-membership row the user has touched,
49/// so making them queryable by UUID is not an additional leak. The
50/// alternative — only letting users you share a group with see your
51/// profile — would force separate code paths in the library / groups
52/// listings just to render the same names already visible there.
53///
54/// # Errors
55///
56/// Returns [`Error::NotFound`] if the user does not exist.
57pub async fn get(
58    _user: CurrentUser,
59    State(state): State<AppState>,
60    Path(user_id): Path<Uuid>,
61) -> Result<Json<UserProfile>, Error> {
62    let row: Option<(String, String, DateTime<Utc>)> =
63        sqlx::query_as("SELECT username, legacy_name, created_at FROM users WHERE user_id = ?1")
64            .bind(user_id.as_bytes().to_vec())
65            .fetch_optional(&state.db)
66            .await
67            .map_err(|err| {
68                tracing::error!("user profile lookup failed: {err}");
69                Error::Database
70            })?;
71    let (username, legacy_name, created_at) =
72        row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))?;
73    Ok(Json(UserProfile {
74        user_id,
75        username,
76        legacy_name,
77        created_at,
78    }))
79}
80
81/// `DELETE /api/users/me` — delete the calling user's account.
82///
83/// Refuses with [`Error::BadRequest`] if the caller is the sole owner
84/// of any group, listing the offending groups so the user knows what
85/// to fix. Otherwise the user row is removed in a single statement;
86/// FK cascades clean up sessions, memberships, invitations, rate-bucket
87/// state, and personal-scope notecards / renders. Group-scope uploads
88/// the user contributed survive: the audit FKs are `ON DELETE SET NULL`
89/// so the rows stay but no longer name the deleted user.
90///
91/// The session cookie is cleared in the response so the browser does
92/// not keep sending a now-invalid id (the session row itself is gone
93/// via cascade, but the cookie clear avoids one extra round-trip
94/// before the user is bounced back to /login).
95///
96/// # Errors
97///
98/// Returns [`Error::BadRequest`] for sole-ownership; [`Error::Database`]
99/// on any underlying query failure.
100pub async fn delete_me(
101    user: CurrentUser,
102    State(state): State<AppState>,
103    jar: SignedCookieJar,
104) -> Result<(SignedCookieJar, Response), Error> {
105    // Sole-owner check: every group where the caller is an `owner` and
106    // is the only `owner` blocks deletion. A multi-owner group is fine
107    // because the user's own membership cascades and the remaining
108    // owner(s) keep the group whole.
109    let sole_owner_groups: Vec<(Vec<u8>, String)> = sqlx::query_as(
110        "SELECT g.group_id, g.name \
111         FROM \"groups\" AS g \
112         JOIN group_memberships AS gm \
113           ON gm.group_id = g.group_id \
114         WHERE gm.user_id = ?1 \
115           AND gm.role = 'owner' \
116           AND (SELECT COUNT(*) FROM group_memberships \
117                WHERE group_id = g.group_id AND role = 'owner') = 1",
118    )
119    .bind(user.user_id.as_bytes().to_vec())
120    .fetch_all(&state.db)
121    .await
122    .map_err(|err| {
123        tracing::error!("sole-owner precheck failed: {err}");
124        Error::Database
125    })?;
126    if !sole_owner_groups.is_empty() {
127        let names: Vec<String> = sole_owner_groups.into_iter().map(|(_, n)| n).collect();
128        return Err(Error::BadRequest(format!(
129            "you are the sole owner of the following group(s): {}. \
130             Promote another member to owner or delete the group(s) first.",
131            names.join(", ")
132        )));
133    }
134
135    let result = sqlx::query("DELETE FROM users WHERE user_id = ?1")
136        .bind(user.user_id.as_bytes().to_vec())
137        .execute(&state.db)
138        .await
139        .map_err(|err| {
140            tracing::error!("delete user failed: {err}");
141            Error::Database
142        })?;
143    if result.rows_affected() == 0 {
144        // The row vanished out from under us between the precheck and
145        // here — treat as already-deleted from the caller's POV.
146        tracing::warn!("delete_me: user row was already gone");
147    }
148    // Best-effort session-id removal (the cascade already nuked the row,
149    // but `delete_session` is a no-op on a missing id, so it is cheap
150    // to call defensively).
151    if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
152        drop(auth::delete_session(&state.db, &session_id).await);
153    }
154    let cleared = jar.add(removal_cookie(&state.config));
155    Ok((cleared, (ReqwestStatusCode::NO_CONTENT, "").into_response()))
156}
157
158/// JSON body for `PATCH /api/users/me/preferences`. A `null`
159/// `route_color` clears the preference (back to the picker default).
160#[derive(Debug, Deserialize)]
161pub struct UpdatePreferences {
162    /// new route colour as canonical `#rrggbb`, or `None` to clear.
163    pub route_color: Option<String>,
164}
165
166/// `PATCH /api/users/me/preferences` — update the calling user's
167/// preferences. Currently the only preference is `route_color`, the
168/// default for the renderer page's route-colour picker.
169///
170/// # Errors
171///
172/// Returns [`Error::BadRequest`] if `route_color` is supplied but does
173/// not match the canonical `#rrggbb` format; [`Error::Database`] on
174/// underlying query failure.
175pub async fn update_preferences(
176    user: CurrentUser,
177    State(state): State<AppState>,
178    Json(req): Json<UpdatePreferences>,
179) -> Result<Response, Error> {
180    if let Some(ref c) = req.route_color
181        && !is_canonical_hex_color(c)
182    {
183        return Err(Error::BadRequest(format!(
184            "route_color must be canonical `#rrggbb`, got {c:?}"
185        )));
186    }
187    sqlx::query("UPDATE users SET route_color = ?1 WHERE user_id = ?2")
188        .bind(&req.route_color)
189        .bind(user.user_id.as_bytes().to_vec())
190        .execute(&state.db)
191        .await
192        .map_err(|err| {
193            tracing::error!("update preferences failed: {err}");
194            Error::Database
195        })?;
196    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
197}
198
199/// Response body for `GET /api/users/me/colors`: the caller's saved-colour
200/// palette as canonical `#rrggbb` strings, oldest first.
201#[derive(Debug, Serialize)]
202pub struct SavedColors {
203    /// the saved colours, ordered by save time (oldest first).
204    pub colors: Vec<String>,
205}
206
207/// `GET /api/users/me/colors` — list the calling user's saved custom
208/// colours. These are shared across every colour picker on the renderer
209/// page (surfaced as a single `<datalist>` of preset swatches).
210///
211/// # Errors
212///
213/// Returns [`Error::Database`] on any underlying query failure.
214pub async fn list_colors(
215    user: CurrentUser,
216    State(state): State<AppState>,
217) -> Result<Json<SavedColors>, Error> {
218    let colors: Vec<String> = sqlx::query_scalar(
219        "SELECT color FROM saved_colors WHERE user_id = ?1 ORDER BY created_at, color",
220    )
221    .bind(user.user_id.as_bytes().to_vec())
222    .fetch_all(&state.db)
223    .await
224    .map_err(|err| {
225        tracing::error!("list saved colors failed: {err}");
226        Error::Database
227    })?;
228    Ok(Json(SavedColors { colors }))
229}
230
231/// JSON body for `POST /api/users/me/colors`.
232#[derive(Debug, Deserialize)]
233pub struct AddColor {
234    /// the colour to save, as canonical `#rrggbb`.
235    pub color: String,
236}
237
238/// `POST /api/users/me/colors` — add a colour to the calling user's saved
239/// palette. Idempotent: re-saving an existing colour is a no-op (the
240/// `(user_id, color)` primary key absorbs the duplicate via
241/// `INSERT OR IGNORE`).
242///
243/// # Errors
244///
245/// Returns [`Error::BadRequest`] if `color` is not canonical `#rrggbb`;
246/// [`Error::Database`] on underlying query failure.
247pub async fn add_color(
248    user: CurrentUser,
249    State(state): State<AppState>,
250    Json(req): Json<AddColor>,
251) -> Result<Response, Error> {
252    if !is_canonical_hex_color(&req.color) {
253        return Err(Error::BadRequest(format!(
254            "color must be canonical `#rrggbb`, got {:?}",
255            req.color
256        )));
257    }
258    sqlx::query(
259        "INSERT OR IGNORE INTO saved_colors (user_id, color, created_at) VALUES (?1, ?2, ?3)",
260    )
261    .bind(user.user_id.as_bytes().to_vec())
262    .bind(&req.color)
263    .bind(Utc::now())
264    .execute(&state.db)
265    .await
266    .map_err(|err| {
267        tracing::error!("add saved color failed: {err}");
268        Error::Database
269    })?;
270    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
271}
272
273/// `DELETE /api/users/me/colors/{color}` — remove a colour from the
274/// calling user's saved palette. `{color}` is the bare six hex digits
275/// **without** the leading `#` (which would otherwise be read as a URL
276/// fragment); the handler re-prefixes it before validating and deleting.
277/// Deleting a colour that is not in the palette succeeds as a no-op.
278///
279/// # Errors
280///
281/// Returns [`Error::BadRequest`] if the path segment is not six hex
282/// digits; [`Error::Database`] on underlying query failure.
283pub async fn delete_color(
284    user: CurrentUser,
285    State(state): State<AppState>,
286    Path(color): Path<String>,
287) -> Result<Response, Error> {
288    let canonical = format!("#{color}");
289    if !is_canonical_hex_color(&canonical) {
290        return Err(Error::BadRequest(format!(
291            "color path segment must be six hex digits, got {color:?}"
292        )));
293    }
294    sqlx::query("DELETE FROM saved_colors WHERE user_id = ?1 AND color = ?2")
295        .bind(user.user_id.as_bytes().to_vec())
296        .bind(&canonical)
297        .execute(&state.db)
298        .await
299        .map_err(|err| {
300            tracing::error!("delete saved color failed: {err}");
301            Error::Database
302        })?;
303    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
304}
305
306#[cfg(test)]
307mod tests {
308    use super::is_canonical_hex_color;
309
310    #[test]
311    fn accepts_canonical_six_digit_hex() {
312        assert!(is_canonical_hex_color("#ff0000"));
313        assert!(is_canonical_hex_color("#FF00aa"));
314        assert!(is_canonical_hex_color("#000000"));
315    }
316
317    #[test]
318    fn rejects_non_canonical() {
319        assert!(!is_canonical_hex_color("ff0000"), "missing leading #");
320        assert!(
321            !is_canonical_hex_color("#fff"),
322            "shorthand is not canonical"
323        );
324        assert!(!is_canonical_hex_color("#ff00000"), "too many digits");
325        assert!(!is_canonical_hex_color("#ff00gg"), "non-hex digit");
326        assert!(!is_canonical_hex_color("#ff 000"), "embedded space");
327        assert!(!is_canonical_hex_color(""), "empty string");
328        assert!(!is_canonical_hex_color("#"), "hash only");
329    }
330}