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;
use crate::library::is_canonical_hex_color;
#[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())
}
#[derive(Debug, Serialize)]
pub struct SavedColors {
pub colors: Vec<String>,
}
pub async fn list_colors(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<SavedColors>, Error> {
let colors: Vec<String> = sqlx::query_scalar(
"SELECT color FROM saved_colors WHERE user_id = ?1 ORDER BY created_at, color",
)
.bind(user.user_id.as_bytes().to_vec())
.fetch_all(&state.db)
.await
.map_err(|err| {
tracing::error!("list saved colors failed: {err}");
Error::Database
})?;
Ok(Json(SavedColors { colors }))
}
#[derive(Debug, Deserialize)]
pub struct AddColor {
pub color: String,
}
pub async fn add_color(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<AddColor>,
) -> Result<Response, Error> {
if !is_canonical_hex_color(&req.color) {
return Err(Error::BadRequest(format!(
"color must be canonical `#rrggbb`, got {:?}",
req.color
)));
}
sqlx::query(
"INSERT OR IGNORE INTO saved_colors (user_id, color, created_at) VALUES (?1, ?2, ?3)",
)
.bind(user.user_id.as_bytes().to_vec())
.bind(&req.color)
.bind(Utc::now())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("add saved color failed: {err}");
Error::Database
})?;
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
pub async fn delete_color(
user: CurrentUser,
State(state): State<AppState>,
Path(color): Path<String>,
) -> Result<Response, Error> {
let canonical = format!("#{color}");
if !is_canonical_hex_color(&canonical) {
return Err(Error::BadRequest(format!(
"color path segment must be six hex digits, got {color:?}"
)));
}
sqlx::query("DELETE FROM saved_colors WHERE user_id = ?1 AND color = ?2")
.bind(user.user_id.as_bytes().to_vec())
.bind(&canonical)
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("delete saved color failed: {err}");
Error::Database
})?;
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
#[cfg(test)]
mod tests {
use super::is_canonical_hex_color;
#[test]
fn accepts_canonical_six_digit_hex() {
assert!(is_canonical_hex_color("#ff0000"));
assert!(is_canonical_hex_color("#FF00aa"));
assert!(is_canonical_hex_color("#000000"));
}
#[test]
fn rejects_non_canonical() {
assert!(!is_canonical_hex_color("ff0000"), "missing leading #");
assert!(
!is_canonical_hex_color("#fff"),
"shorthand is not canonical"
);
assert!(!is_canonical_hex_color("#ff00000"), "too many digits");
assert!(!is_canonical_hex_color("#ff00gg"), "non-hex digit");
assert!(!is_canonical_hex_color("#ff 000"), "embedded space");
assert!(!is_canonical_hex_color(""), "empty string");
assert!(!is_canonical_hex_color("#"), "hash only");
}
}