use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::response::{IntoResponse as _, Response};
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{self, CurrentUser, removal_cookie};
use crate::error::Error;
use crate::state::AppState;
fn is_canonical_hex_color(s: &str) -> bool {
let mut chars = s.chars();
if chars.next() != Some('#') {
return false;
}
let mut count = 0_usize;
for c in chars {
if !c.is_ascii_hexdigit() {
return false;
}
count = count.saturating_add(1);
}
count == 6
}
#[derive(Debug, Clone, Serialize)]
pub struct UserProfile {
pub user_id: Uuid,
pub username: String,
pub legacy_name: String,
pub created_at: DateTime<Utc>,
}
pub async fn get(
_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<Json<UserProfile>, Error> {
let row: Option<(String, String, DateTime<Utc>)> =
sqlx::query_as("SELECT username, legacy_name, created_at FROM users WHERE user_id = ?1")
.bind(user_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("user profile lookup failed: {err}");
Error::Database
})?;
let (username, legacy_name, created_at) =
row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))?;
Ok(Json(UserProfile {
user_id,
username,
legacy_name,
created_at,
}))
}
pub async fn delete_me(
user: CurrentUser,
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<(SignedCookieJar, Response), Error> {
let sole_owner_groups: Vec<(Vec<u8>, String)> = sqlx::query_as(
"SELECT g.group_id, g.name \
FROM \"groups\" AS g \
JOIN group_memberships AS gm \
ON gm.group_id = g.group_id \
WHERE gm.user_id = ?1 \
AND gm.role = 'owner' \
AND (SELECT COUNT(*) FROM group_memberships \
WHERE group_id = g.group_id AND role = 'owner') = 1",
)
.bind(user.user_id.as_bytes().to_vec())
.fetch_all(&state.db)
.await
.map_err(|err| {
tracing::error!("sole-owner precheck failed: {err}");
Error::Database
})?;
if !sole_owner_groups.is_empty() {
let names: Vec<String> = sole_owner_groups.into_iter().map(|(_, n)| n).collect();
return Err(Error::BadRequest(format!(
"you are the sole owner of the following group(s): {}. \
Promote another member to owner or delete the group(s) first.",
names.join(", ")
)));
}
let result = sqlx::query("DELETE FROM users WHERE user_id = ?1")
.bind(user.user_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("delete user failed: {err}");
Error::Database
})?;
if result.rows_affected() == 0 {
tracing::warn!("delete_me: user row was already gone");
}
if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
drop(auth::delete_session(&state.db, &session_id).await);
}
let cleared = jar.add(removal_cookie(&state.config));
Ok((cleared, (ReqwestStatusCode::NO_CONTENT, "").into_response()))
}
#[derive(Debug, Deserialize)]
pub struct UpdatePreferences {
pub route_color: Option<String>,
}
pub async fn update_preferences(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<UpdatePreferences>,
) -> Result<Response, Error> {
if let Some(ref c) = req.route_color
&& !is_canonical_hex_color(c)
{
return Err(Error::BadRequest(format!(
"route_color must be canonical `#rrggbb`, got {c:?}"
)));
}
sqlx::query("UPDATE users SET route_color = ?1 WHERE user_id = ?2")
.bind(&req.route_color)
.bind(user.user_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("update preferences failed: {err}");
Error::Database
})?;
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}