1use axum::Json;
14use axum::extract::{Path, Query, State};
15use axum::http::StatusCode as ReqwestStatusCode;
16use axum::response::{IntoResponse as _, Response};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::auth::{CurrentUser, uuid_from_bytes};
22use crate::error::{self, Error};
23use crate::library::{self, Destination, ThemeRow, is_canonical_hex_color};
24use crate::routes::render::GlwStyleOverrides;
25use crate::state::AppState;
26
27const THEME_SETTINGS_VERSION: u32 = 1;
30
31const fn default_version() -> u32 {
33 THEME_SETTINGS_VERSION
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
41#[expect(
42 clippy::struct_excessive_bools,
43 reason = "this is a flat presentation-settings record; each bool maps directly to one independent checkbox in the render form"
44)]
45pub struct ThemeSettings {
46 #[serde(default = "default_version")]
48 pub version: u32,
49 #[serde(default)]
51 pub missing_map_tile_enabled: bool,
52 #[serde(default)]
54 pub missing_map_tile_color: Option<String>,
55 #[serde(default)]
57 pub missing_region_enabled: bool,
58 #[serde(default)]
60 pub missing_region_color: Option<String>,
61 #[serde(default)]
63 pub draw_region_rectangles: bool,
64 #[serde(default)]
66 pub draw_region_names: bool,
67 #[serde(default)]
69 pub draw_region_coordinates: bool,
70 #[serde(default)]
72 pub region_label_font_id: Option<String>,
73 #[serde(default)]
76 pub glw_style: GlwStyleOverrides,
77 #[serde(default)]
79 pub glw_font_id: Option<String>,
80 #[serde(default)]
82 pub route_color: Option<String>,
83}
84
85impl ThemeSettings {
86 fn validate(&self) -> Result<(), Error> {
93 let colors = [
94 ("missing_map_tile_color", &self.missing_map_tile_color),
95 ("missing_region_color", &self.missing_region_color),
96 (
97 "glw_style.area_outline_color",
98 &self.glw_style.area_outline_color,
99 ),
100 (
101 "glw_style.circle_outline_color",
102 &self.glw_style.circle_outline_color,
103 ),
104 (
105 "glw_style.margin_outline_color",
106 &self.glw_style.margin_outline_color,
107 ),
108 ("glw_style.wind_color", &self.glw_style.wind_color),
109 ("glw_style.current_color", &self.glw_style.current_color),
110 ("glw_style.wave_color", &self.glw_style.wave_color),
111 ("glw_style.label_color", &self.glw_style.label_color),
112 ("route_color", &self.route_color),
113 ];
114 for (field, value) in colors {
115 if let Some(c) = value
116 && !is_canonical_hex_color(c)
117 {
118 return Err(Error::BadRequest(format!(
119 "{field} must be canonical `#rrggbb`, got {c:?}"
120 )));
121 }
122 }
123 Ok(())
124 }
125}
126
127#[derive(Debug, Serialize)]
129pub struct ThemeView {
130 pub theme_id: Uuid,
132 pub destination: Destination,
134 pub created_by: Option<Uuid>,
137 pub created_by_username: Option<String>,
139 pub created_by_legacy_name: Option<String>,
141 pub name: String,
143 pub settings: ThemeSettings,
145 pub created_at: DateTime<Utc>,
147 pub updated_at: DateTime<Utc>,
149}
150
151#[derive(Debug, Deserialize)]
153pub struct ListQuery {
154 pub scope: String,
156}
157
158#[derive(Debug, Serialize)]
160pub struct ListThemesResponse {
161 pub themes: Vec<ThemeView>,
163}
164
165#[derive(Debug, Serialize)]
167pub struct ThemeResponse {
168 pub theme: ThemeView,
170}
171
172#[derive(Debug, Deserialize)]
174pub struct CreateThemeRequest {
175 pub scope: String,
177 pub name: String,
179 pub settings: ThemeSettings,
181}
182
183#[derive(Debug, Deserialize)]
186pub struct UpdateThemeRequest {
187 #[serde(default)]
189 pub name: Option<String>,
190 #[serde(default)]
192 pub settings: Option<ThemeSettings>,
193}
194
195fn parse_settings(settings_json: &str) -> Result<ThemeSettings, Error> {
197 serde_json::from_str(settings_json).map_err(|err| {
198 tracing::error!("theme settings_json parse failed: {err}");
199 Error::Database
200 })
201}
202
203fn build_view(
206 row: ThemeRow,
207 destination: Destination,
208 created_by_username: Option<String>,
209 created_by_legacy_name: Option<String>,
210) -> Result<ThemeView, Error> {
211 let settings = parse_settings(&row.settings_json)?;
212 Ok(ThemeView {
213 theme_id: row.theme_id,
214 destination,
215 created_by: row.created_by,
216 created_by_username,
217 created_by_legacy_name,
218 name: row.name,
219 settings,
220 created_at: row.created_at,
221 updated_at: row.updated_at,
222 })
223}
224
225async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
228 let row: Option<(String, String)> =
229 sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
230 .bind(user_id.as_bytes().to_vec())
231 .fetch_optional(&state.db)
232 .await
233 .map_err(|err| {
234 tracing::error!("user name lookup failed: {err}");
235 Error::Database
236 })?;
237 row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
238}
239
240async fn resolve_creator(
243 state: &AppState,
244 created_by: Option<Uuid>,
245) -> (Option<String>, Option<String>) {
246 match created_by {
247 Some(id) => match lookup_user_names(state, id).await {
248 Ok((u, l)) => (Some(u), Some(l)),
249 Err(_) => (None, None),
250 },
251 None => (None, None),
252 }
253}
254
255#[derive(sqlx::FromRow)]
257struct ThemeListRow {
258 theme_id: Vec<u8>,
260 owner_user_id: Option<Vec<u8>>,
262 owner_group_id: Option<Vec<u8>>,
264 created_by: Option<Vec<u8>>,
266 created_by_username: Option<String>,
268 created_by_legacy_name: Option<String>,
270 name: String,
272 settings_json: String,
274 created_at: DateTime<Utc>,
276 updated_at: DateTime<Utc>,
278}
279
280pub async fn list(
289 user: CurrentUser,
290 State(state): State<AppState>,
291 Query(query): Query<ListQuery>,
292) -> Result<Json<ListThemesResponse>, Error> {
293 let destination = Destination::parse(&query.scope)?;
294 library::assert_can_view(&state.db, user.user_id, destination).await?;
295
296 let rows: Vec<ThemeListRow> = match destination {
299 Destination::Personal => {
300 sqlx::query_as(
301 "SELECT t.theme_id, t.owner_user_id, t.owner_group_id, t.created_by, \
302 u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
303 t.name, t.settings_json, t.created_at, t.updated_at \
304 FROM themes AS t \
305 LEFT JOIN users AS u ON u.user_id = t.created_by \
306 WHERE t.owner_user_id = ?1 \
307 ORDER BY t.created_at DESC",
308 )
309 .bind(user.user_id.as_bytes().to_vec())
310 .fetch_all(&state.db)
311 .await
312 }
313 Destination::Group { group_id } => {
314 sqlx::query_as(
317 "SELECT t.theme_id, t.owner_user_id, t.owner_group_id, t.created_by, \
318 u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
319 t.name, t.settings_json, t.created_at, t.updated_at \
320 FROM themes AS t \
321 LEFT JOIN users AS u ON u.user_id = t.created_by \
322 JOIN group_memberships AS gm \
323 ON gm.group_id = t.owner_group_id AND gm.user_id = ?2 \
324 WHERE t.owner_group_id = ?1 \
325 ORDER BY t.created_at DESC",
326 )
327 .bind(group_id.as_bytes().to_vec())
328 .bind(user.user_id.as_bytes().to_vec())
329 .fetch_all(&state.db)
330 .await
331 }
332 }
333 .map_err(|err| {
334 tracing::error!("list themes failed: {err}");
335 Error::Database
336 })?;
337
338 let mut themes = Vec::with_capacity(rows.len());
339 for row in rows {
340 let theme_id = uuid_from_bytes(&row.theme_id).ok_or_else(|| {
341 tracing::error!("bad theme uuid");
342 Error::Database
343 })?;
344 let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
345 let created_by = row
346 .created_by
347 .as_deref()
348 .map(uuid_from_bytes)
349 .map(|opt| {
350 opt.ok_or_else(|| {
351 tracing::error!("bad created_by uuid in themes");
352 Error::Database
353 })
354 })
355 .transpose()?;
356 let settings = parse_settings(&row.settings_json)?;
357 themes.push(ThemeView {
358 theme_id,
359 destination: row_dest,
360 created_by,
361 created_by_username: row.created_by_username,
362 created_by_legacy_name: row.created_by_legacy_name,
363 name: row.name,
364 settings,
365 created_at: row.created_at,
366 updated_at: row.updated_at,
367 });
368 }
369 Ok(Json(ListThemesResponse { themes }))
370}
371
372pub async fn create(
379 user: CurrentUser,
380 State(state): State<AppState>,
381 Json(body): Json<CreateThemeRequest>,
382) -> Result<Json<ThemeResponse>, Error> {
383 let destination = Destination::parse(&body.scope)?;
384 library::assert_can_write(&state.db, user.user_id, destination).await?;
385 let name = library::sanitise_display_name(&body.name, "theme name")?;
386 body.settings.validate()?;
387
388 let settings_json = serde_json::to_string(&body.settings).map_err(|err| {
389 tracing::error!("theme settings serialise failed: {err}");
390 Error::Database
391 })?;
392 let theme_id = Uuid::new_v4();
393 let now = Utc::now();
394 let (owner_user, owner_group) = match destination {
395 Destination::Personal => (Some(user.user_id.as_bytes().to_vec()), None),
396 Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
397 };
398 sqlx::query(
399 "INSERT INTO themes \
400 (theme_id, owner_user_id, owner_group_id, created_by, name, \
401 settings_json, created_at, updated_at) \
402 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
403 )
404 .bind(theme_id.as_bytes().to_vec())
405 .bind(owner_user)
406 .bind(owner_group)
407 .bind(user.user_id.as_bytes().to_vec())
408 .bind(&name)
409 .bind(&settings_json)
410 .bind(now)
411 .execute(&state.db)
412 .await
413 .map_err(|err| {
414 if error::is_unique_violation(&err) {
415 return Error::BadRequest(format!(
416 "a theme named {name:?} already exists in this scope; pick a different name"
417 ));
418 }
419 tracing::error!("insert theme failed: {err}");
420 Error::Database
421 })?;
422
423 let view = ThemeView {
424 theme_id,
425 destination,
426 created_by: Some(user.user_id),
427 created_by_username: Some(user.username.clone()),
428 created_by_legacy_name: Some(user.legacy_name.clone()),
429 name,
430 settings: body.settings,
431 created_at: now,
432 updated_at: now,
433 };
434 Ok(Json(ThemeResponse { theme: view }))
435}
436
437pub async fn get(
444 user: CurrentUser,
445 State(state): State<AppState>,
446 Path(theme_id): Path<Uuid>,
447) -> Result<Json<ThemeResponse>, Error> {
448 let row = library::assert_can_read_theme(&state.db, user.user_id, theme_id).await?;
449 let destination =
450 library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
451 let (username, legacy) = resolve_creator(&state, row.created_by).await;
452 let view = build_view(row, destination, username, legacy)?;
453 Ok(Json(ThemeResponse { theme: view }))
454}
455
456pub async fn update(
463 user: CurrentUser,
464 State(state): State<AppState>,
465 Path(theme_id): Path<Uuid>,
466 Json(body): Json<UpdateThemeRequest>,
467) -> Result<Json<ThemeResponse>, Error> {
468 if body.name.is_none() && body.settings.is_none() {
469 return Err(Error::BadRequest(
470 "PATCH must set at least one of `name` or `settings`".to_owned(),
471 ));
472 }
473 let mut row = library::assert_can_modify_theme(&state.db, user.user_id, theme_id).await?;
474
475 let new_name = match &body.name {
476 Some(raw) => Some(library::sanitise_display_name(raw, "theme name")?),
477 None => None,
478 };
479 let new_settings_json = match &body.settings {
480 Some(settings) => {
481 settings.validate()?;
482 Some(serde_json::to_string(settings).map_err(|err| {
483 tracing::error!("theme settings serialise failed: {err}");
484 Error::Database
485 })?)
486 }
487 None => None,
488 };
489 let now = Utc::now();
490 sqlx::query(
491 "UPDATE themes \
492 SET name = COALESCE(?1, name), \
493 settings_json = COALESCE(?2, settings_json), \
494 updated_at = ?3 \
495 WHERE theme_id = ?4",
496 )
497 .bind(new_name.as_deref())
498 .bind(new_settings_json.as_deref())
499 .bind(now)
500 .bind(theme_id.as_bytes().to_vec())
501 .execute(&state.db)
502 .await
503 .map_err(|err| {
504 if error::is_unique_violation(&err) {
505 return Error::BadRequest(
506 "a theme with that name already exists in this scope; pick a different name"
507 .to_owned(),
508 );
509 }
510 tracing::error!("update theme failed: {err}");
511 Error::Database
512 })?;
513
514 if let Some(name) = new_name {
517 row.name = name;
518 }
519 if let Some(json) = new_settings_json {
520 row.settings_json = json;
521 }
522 row.updated_at = now;
523 let destination =
524 library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
525 let (username, legacy) = resolve_creator(&state, row.created_by).await;
526 let view = build_view(row, destination, username, legacy)?;
527 Ok(Json(ThemeResponse { theme: view }))
528}
529
530pub async fn delete(
537 user: CurrentUser,
538 State(state): State<AppState>,
539 Path(theme_id): Path<Uuid>,
540) -> Result<Response, Error> {
541 library::assert_can_modify_theme(&state.db, user.user_id, theme_id).await?;
542 sqlx::query("DELETE FROM themes WHERE theme_id = ?1")
543 .bind(theme_id.as_bytes().to_vec())
544 .execute(&state.db)
545 .await
546 .map_err(|err| {
547 tracing::error!("delete theme failed: {err}");
548 Error::Database
549 })?;
550 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
551}
552
553#[cfg(test)]
554mod tests {
555 #![allow(
556 clippy::expect_used,
557 reason = "test code panics on failure for clearer output"
558 )]
559
560 use pretty_assertions::assert_eq;
561
562 use super::{GlwStyleOverrides, ThemeSettings};
563
564 fn minimal() -> ThemeSettings {
567 ThemeSettings {
568 version: 1,
569 missing_map_tile_enabled: false,
570 missing_map_tile_color: None,
571 missing_region_enabled: false,
572 missing_region_color: None,
573 draw_region_rectangles: false,
574 draw_region_names: false,
575 draw_region_coordinates: false,
576 region_label_font_id: None,
577 glw_style: GlwStyleOverrides::default(),
578 glw_font_id: None,
579 route_color: None,
580 }
581 }
582
583 #[test]
584 fn accepts_canonical_colors() {
585 let mut s = minimal();
586 s.missing_map_tile_color = Some("#ff0000".to_owned());
587 s.route_color = Some("#00FF00".to_owned());
588 s.glw_style.wind_color = Some("#123abc".to_owned());
589 s.validate().expect("canonical colours validate");
590 }
591
592 #[test]
593 fn rejects_malformed_top_level_color() {
594 let mut s = minimal();
595 s.missing_region_color = Some("ff0000".to_owned()); assert!(s.validate().is_err());
597 }
598
599 #[test]
600 fn rejects_malformed_glw_color() {
601 let mut s = minimal();
602 s.glw_style.label_color = Some("#fff".to_owned()); assert!(s.validate().is_err());
604 }
605
606 #[test]
607 fn none_colors_are_allowed() {
608 minimal()
609 .validate()
610 .expect("a theme with no colours validates");
611 }
612
613 #[test]
614 fn round_trips_through_json() {
615 let mut s = minimal();
616 s.draw_region_names = true;
617 s.region_label_font_id = Some("DejaVuSans.ttf".to_owned());
618 s.route_color = Some("#abcdef".to_owned());
619 let json = serde_json::to_string(&s).expect("serialise");
620 let back: ThemeSettings = serde_json::from_str(&json).expect("deserialise");
621 assert_eq!(back.draw_region_names, s.draw_region_names);
622 assert_eq!(back.region_label_font_id, s.region_label_font_id);
623 assert_eq!(back.route_color, s.route_color);
624 }
625
626 #[test]
627 fn version_defaults_when_absent() {
628 let back: ThemeSettings = serde_json::from_str("{}").expect("deserialise empty");
630 assert_eq!(back.version, 1);
631 assert!(!back.missing_map_tile_enabled);
632 }
633}