use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::response::{IntoResponse as _, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{CurrentUser, uuid_from_bytes};
use crate::error::{self, Error};
use crate::library::{self, Destination, ThemeRow, is_canonical_hex_color};
use crate::routes::render::GlwStyleOverrides;
use crate::state::AppState;
const THEME_SETTINGS_VERSION: u32 = 1;
const fn default_version() -> u32 {
THEME_SETTINGS_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[expect(
clippy::struct_excessive_bools,
reason = "this is a flat presentation-settings record; each bool maps directly to one independent checkbox in the render form"
)]
pub struct ThemeSettings {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub missing_map_tile_enabled: bool,
#[serde(default)]
pub missing_map_tile_color: Option<String>,
#[serde(default)]
pub missing_region_enabled: bool,
#[serde(default)]
pub missing_region_color: Option<String>,
#[serde(default)]
pub draw_region_rectangles: bool,
#[serde(default)]
pub draw_region_names: bool,
#[serde(default)]
pub draw_region_coordinates: bool,
#[serde(default)]
pub region_label_font_id: Option<String>,
#[serde(default)]
pub glw_style: GlwStyleOverrides,
#[serde(default)]
pub glw_font_id: Option<String>,
#[serde(default)]
pub route_color: Option<String>,
}
impl ThemeSettings {
fn validate(&self) -> Result<(), Error> {
let colors = [
("missing_map_tile_color", &self.missing_map_tile_color),
("missing_region_color", &self.missing_region_color),
(
"glw_style.area_outline_color",
&self.glw_style.area_outline_color,
),
(
"glw_style.circle_outline_color",
&self.glw_style.circle_outline_color,
),
(
"glw_style.margin_outline_color",
&self.glw_style.margin_outline_color,
),
("glw_style.wind_color", &self.glw_style.wind_color),
("glw_style.current_color", &self.glw_style.current_color),
("glw_style.wave_color", &self.glw_style.wave_color),
("glw_style.label_color", &self.glw_style.label_color),
("route_color", &self.route_color),
];
for (field, value) in colors {
if let Some(c) = value
&& !is_canonical_hex_color(c)
{
return Err(Error::BadRequest(format!(
"{field} must be canonical `#rrggbb`, got {c:?}"
)));
}
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct ThemeView {
pub theme_id: Uuid,
pub destination: Destination,
pub created_by: Option<Uuid>,
pub created_by_username: Option<String>,
pub created_by_legacy_name: Option<String>,
pub name: String,
pub settings: ThemeSettings,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub scope: String,
}
#[derive(Debug, Serialize)]
pub struct ListThemesResponse {
pub themes: Vec<ThemeView>,
}
#[derive(Debug, Serialize)]
pub struct ThemeResponse {
pub theme: ThemeView,
}
#[derive(Debug, Deserialize)]
pub struct CreateThemeRequest {
pub scope: String,
pub name: String,
pub settings: ThemeSettings,
}
#[derive(Debug, Deserialize)]
pub struct UpdateThemeRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub settings: Option<ThemeSettings>,
}
fn parse_settings(settings_json: &str) -> Result<ThemeSettings, Error> {
serde_json::from_str(settings_json).map_err(|err| {
tracing::error!("theme settings_json parse failed: {err}");
Error::Database
})
}
fn build_view(
row: ThemeRow,
destination: Destination,
created_by_username: Option<String>,
created_by_legacy_name: Option<String>,
) -> Result<ThemeView, Error> {
let settings = parse_settings(&row.settings_json)?;
Ok(ThemeView {
theme_id: row.theme_id,
destination,
created_by: row.created_by,
created_by_username,
created_by_legacy_name,
name: row.name,
settings,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
let row: Option<(String, String)> =
sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
.bind(user_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("user name lookup failed: {err}");
Error::Database
})?;
row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
}
async fn resolve_creator(
state: &AppState,
created_by: Option<Uuid>,
) -> (Option<String>, Option<String>) {
match created_by {
Some(id) => match lookup_user_names(state, id).await {
Ok((u, l)) => (Some(u), Some(l)),
Err(_) => (None, None),
},
None => (None, None),
}
}
#[derive(sqlx::FromRow)]
struct ThemeListRow {
theme_id: Vec<u8>,
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
created_by: Option<Vec<u8>>,
created_by_username: Option<String>,
created_by_legacy_name: Option<String>,
name: String,
settings_json: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
pub async fn list(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<ListThemesResponse>, Error> {
let destination = Destination::parse(&query.scope)?;
library::assert_can_view(&state.db, user.user_id, destination).await?;
let rows: Vec<ThemeListRow> = match destination {
Destination::Personal => {
sqlx::query_as(
"SELECT t.theme_id, t.owner_user_id, t.owner_group_id, t.created_by, \
u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
t.name, t.settings_json, t.created_at, t.updated_at \
FROM themes AS t \
LEFT JOIN users AS u ON u.user_id = t.created_by \
WHERE t.owner_user_id = ?1 \
ORDER BY t.created_at DESC",
)
.bind(user.user_id.as_bytes().to_vec())
.fetch_all(&state.db)
.await
}
Destination::Group { group_id } => {
sqlx::query_as(
"SELECT t.theme_id, t.owner_user_id, t.owner_group_id, t.created_by, \
u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
t.name, t.settings_json, t.created_at, t.updated_at \
FROM themes AS t \
LEFT JOIN users AS u ON u.user_id = t.created_by \
JOIN group_memberships AS gm \
ON gm.group_id = t.owner_group_id AND gm.user_id = ?2 \
WHERE t.owner_group_id = ?1 \
ORDER BY t.created_at DESC",
)
.bind(group_id.as_bytes().to_vec())
.bind(user.user_id.as_bytes().to_vec())
.fetch_all(&state.db)
.await
}
}
.map_err(|err| {
tracing::error!("list themes failed: {err}");
Error::Database
})?;
let mut themes = Vec::with_capacity(rows.len());
for row in rows {
let theme_id = uuid_from_bytes(&row.theme_id).ok_or_else(|| {
tracing::error!("bad theme uuid");
Error::Database
})?;
let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
let created_by = row
.created_by
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid in themes");
Error::Database
})
})
.transpose()?;
let settings = parse_settings(&row.settings_json)?;
themes.push(ThemeView {
theme_id,
destination: row_dest,
created_by,
created_by_username: row.created_by_username,
created_by_legacy_name: row.created_by_legacy_name,
name: row.name,
settings,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
Ok(Json(ListThemesResponse { themes }))
}
pub async fn create(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CreateThemeRequest>,
) -> Result<Json<ThemeResponse>, Error> {
let destination = Destination::parse(&body.scope)?;
library::assert_can_write(&state.db, user.user_id, destination).await?;
let name = library::sanitise_display_name(&body.name, "theme name")?;
body.settings.validate()?;
let settings_json = serde_json::to_string(&body.settings).map_err(|err| {
tracing::error!("theme settings serialise failed: {err}");
Error::Database
})?;
let theme_id = Uuid::new_v4();
let now = Utc::now();
let (owner_user, owner_group) = match destination {
Destination::Personal => (Some(user.user_id.as_bytes().to_vec()), None),
Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
};
sqlx::query(
"INSERT INTO themes \
(theme_id, owner_user_id, owner_group_id, created_by, name, \
settings_json, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
)
.bind(theme_id.as_bytes().to_vec())
.bind(owner_user)
.bind(owner_group)
.bind(user.user_id.as_bytes().to_vec())
.bind(&name)
.bind(&settings_json)
.bind(now)
.execute(&state.db)
.await
.map_err(|err| {
if error::is_unique_violation(&err) {
return Error::BadRequest(format!(
"a theme named {name:?} already exists in this scope; pick a different name"
));
}
tracing::error!("insert theme failed: {err}");
Error::Database
})?;
let view = ThemeView {
theme_id,
destination,
created_by: Some(user.user_id),
created_by_username: Some(user.username.clone()),
created_by_legacy_name: Some(user.legacy_name.clone()),
name,
settings: body.settings,
created_at: now,
updated_at: now,
};
Ok(Json(ThemeResponse { theme: view }))
}
pub async fn get(
user: CurrentUser,
State(state): State<AppState>,
Path(theme_id): Path<Uuid>,
) -> Result<Json<ThemeResponse>, Error> {
let row = library::assert_can_read_theme(&state.db, user.user_id, theme_id).await?;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let (username, legacy) = resolve_creator(&state, row.created_by).await;
let view = build_view(row, destination, username, legacy)?;
Ok(Json(ThemeResponse { theme: view }))
}
pub async fn update(
user: CurrentUser,
State(state): State<AppState>,
Path(theme_id): Path<Uuid>,
Json(body): Json<UpdateThemeRequest>,
) -> Result<Json<ThemeResponse>, Error> {
if body.name.is_none() && body.settings.is_none() {
return Err(Error::BadRequest(
"PATCH must set at least one of `name` or `settings`".to_owned(),
));
}
let mut row = library::assert_can_modify_theme(&state.db, user.user_id, theme_id).await?;
let new_name = match &body.name {
Some(raw) => Some(library::sanitise_display_name(raw, "theme name")?),
None => None,
};
let new_settings_json = match &body.settings {
Some(settings) => {
settings.validate()?;
Some(serde_json::to_string(settings).map_err(|err| {
tracing::error!("theme settings serialise failed: {err}");
Error::Database
})?)
}
None => None,
};
let now = Utc::now();
sqlx::query(
"UPDATE themes \
SET name = COALESCE(?1, name), \
settings_json = COALESCE(?2, settings_json), \
updated_at = ?3 \
WHERE theme_id = ?4",
)
.bind(new_name.as_deref())
.bind(new_settings_json.as_deref())
.bind(now)
.bind(theme_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
if error::is_unique_violation(&err) {
return Error::BadRequest(
"a theme with that name already exists in this scope; pick a different name"
.to_owned(),
);
}
tracing::error!("update theme failed: {err}");
Error::Database
})?;
if let Some(name) = new_name {
row.name = name;
}
if let Some(json) = new_settings_json {
row.settings_json = json;
}
row.updated_at = now;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let (username, legacy) = resolve_creator(&state, row.created_by).await;
let view = build_view(row, destination, username, legacy)?;
Ok(Json(ThemeResponse { theme: view }))
}
pub async fn delete(
user: CurrentUser,
State(state): State<AppState>,
Path(theme_id): Path<Uuid>,
) -> Result<Response, Error> {
library::assert_can_modify_theme(&state.db, user.user_id, theme_id).await?;
sqlx::query("DELETE FROM themes WHERE theme_id = ?1")
.bind(theme_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("delete theme failed: {err}");
Error::Database
})?;
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
#[cfg(test)]
mod tests {
#![allow(
clippy::expect_used,
reason = "test code panics on failure for clearer output"
)]
use pretty_assertions::assert_eq;
use super::{GlwStyleOverrides, ThemeSettings};
fn minimal() -> ThemeSettings {
ThemeSettings {
version: 1,
missing_map_tile_enabled: false,
missing_map_tile_color: None,
missing_region_enabled: false,
missing_region_color: None,
draw_region_rectangles: false,
draw_region_names: false,
draw_region_coordinates: false,
region_label_font_id: None,
glw_style: GlwStyleOverrides::default(),
glw_font_id: None,
route_color: None,
}
}
#[test]
fn accepts_canonical_colors() {
let mut s = minimal();
s.missing_map_tile_color = Some("#ff0000".to_owned());
s.route_color = Some("#00FF00".to_owned());
s.glw_style.wind_color = Some("#123abc".to_owned());
s.validate().expect("canonical colours validate");
}
#[test]
fn rejects_malformed_top_level_color() {
let mut s = minimal();
s.missing_region_color = Some("ff0000".to_owned()); assert!(s.validate().is_err());
}
#[test]
fn rejects_malformed_glw_color() {
let mut s = minimal();
s.glw_style.label_color = Some("#fff".to_owned()); assert!(s.validate().is_err());
}
#[test]
fn none_colors_are_allowed() {
minimal()
.validate()
.expect("a theme with no colours validates");
}
#[test]
fn round_trips_through_json() {
let mut s = minimal();
s.draw_region_names = true;
s.region_label_font_id = Some("DejaVuSans.ttf".to_owned());
s.route_color = Some("#abcdef".to_owned());
let json = serde_json::to_string(&s).expect("serialise");
let back: ThemeSettings = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.draw_region_names, s.draw_region_names);
assert_eq!(back.region_label_font_id, s.region_label_font_id);
assert_eq!(back.route_color, s.route_color);
}
#[test]
fn version_defaults_when_absent() {
let back: ThemeSettings = serde_json::from_str("{}").expect("deserialise empty");
assert_eq!(back.version, 1);
assert!(!back.missing_map_tile_enabled);
}
}