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
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}