Skip to main content

sl_map_web/routes/
themes.rs

1//! HTTP handlers for saved themes.
2//!
3//! A *theme* is a named bundle of the render page's presentation settings —
4//! the fill colours and their enable toggles, the region-overlay toggles
5//! and label font, the GLW style overrides and GLW font, and the default
6//! route colour. Users save the current look once and re-apply it later.
7//!
8//! Ownership follows the established Personal-or-Group XOR pattern; the
9//! permission gates live in [`crate::library`] alongside the other library
10//! item types. Group themes are writable by group owners only; every member
11//! can list and apply them.
12
13use 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
27/// Current `settings_json` schema version. Bumped if the shape of
28/// [`ThemeSettings`] ever changes incompatibly.
29const THEME_SETTINGS_VERSION: u32 = 1;
30
31/// Default for the serde `version` field on older / hand-written payloads.
32const fn default_version() -> u32 {
33    THEME_SETTINGS_VERSION
34}
35
36/// The presentation settings captured by a saved theme. Serialised to the
37/// `themes.settings_json` column and applied back onto the render form by
38/// the frontend. Every field is `#[serde(default)]` so a payload written by
39/// an older client (missing a field) still deserialises cleanly.
40#[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    /// payload schema version (see `THEME_SETTINGS_VERSION`).
47    #[serde(default = "default_version")]
48    pub version: u32,
49    /// whether the "fill missing map tiles" option is enabled.
50    #[serde(default)]
51    pub missing_map_tile_enabled: bool,
52    /// fill colour for missing map tiles, as `#rrggbb`.
53    #[serde(default)]
54    pub missing_map_tile_color: Option<String>,
55    /// whether the "fill missing regions" option is enabled.
56    #[serde(default)]
57    pub missing_region_enabled: bool,
58    /// fill colour for missing regions, as `#rrggbb`.
59    #[serde(default)]
60    pub missing_region_color: Option<String>,
61    /// draw a hairline rectangle around each region.
62    #[serde(default)]
63    pub draw_region_rectangles: bool,
64    /// draw each region's name.
65    #[serde(default)]
66    pub draw_region_names: bool,
67    /// draw each region's grid coordinates.
68    #[serde(default)]
69    pub draw_region_coordinates: bool,
70    /// font id used for the region name / coordinate overlay.
71    #[serde(default)]
72    pub region_label_font_id: Option<String>,
73    /// GLW style overrides (margin band + the seven GLW colours). Reuses
74    /// the render module's struct so the JS maps straight onto it.
75    #[serde(default)]
76    pub glw_style: GlwStyleOverrides,
77    /// font id used for GLW labels and the legend.
78    #[serde(default)]
79    pub glw_font_id: Option<String>,
80    /// default route polyline colour, as `#rrggbb`.
81    #[serde(default)]
82    pub route_color: Option<String>,
83}
84
85impl ThemeSettings {
86    /// Validate every colour field is canonical `#rrggbb`. Font ids are
87    /// stored as-is (the render path already tolerates a missing font).
88    ///
89    /// # Errors
90    ///
91    /// Returns [`Error::BadRequest`] for any malformed colour.
92    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/// Public, serialisable record of a saved theme.
128#[derive(Debug, Serialize)]
129pub struct ThemeView {
130    /// the theme id.
131    pub theme_id: Uuid,
132    /// the destination the theme belongs to.
133    pub destination: Destination,
134    /// the avatar that created the theme, or `None` if the account is
135    /// since deleted.
136    pub created_by: Option<Uuid>,
137    /// the creator's username, if the account still exists.
138    pub created_by_username: Option<String>,
139    /// the creator's legacy name, if the account still exists.
140    pub created_by_legacy_name: Option<String>,
141    /// the human-supplied display name.
142    pub name: String,
143    /// the presentation settings.
144    pub settings: ThemeSettings,
145    /// when the theme was created.
146    pub created_at: DateTime<Utc>,
147    /// when the theme was last renamed or its settings overwritten.
148    pub updated_at: DateTime<Utc>,
149}
150
151/// Query parameters for `GET /api/themes`.
152#[derive(Debug, Deserialize)]
153pub struct ListQuery {
154    /// `"personal"` or `"group:<uuid>"`.
155    pub scope: String,
156}
157
158/// Response shape for `GET /api/themes`.
159#[derive(Debug, Serialize)]
160pub struct ListThemesResponse {
161    /// matching themes, newest first.
162    pub themes: Vec<ThemeView>,
163}
164
165/// Response shape for a single theme.
166#[derive(Debug, Serialize)]
167pub struct ThemeResponse {
168    /// the requested theme.
169    pub theme: ThemeView,
170}
171
172/// Body for `POST /api/themes`.
173#[derive(Debug, Deserialize)]
174pub struct CreateThemeRequest {
175    /// destination scope: `"personal"` or `"group:<uuid>"`.
176    pub scope: String,
177    /// the display name for the new theme.
178    pub name: String,
179    /// the presentation settings to store.
180    pub settings: ThemeSettings,
181}
182
183/// Body for `PATCH /api/themes/{id}`. Either or both fields may be set;
184/// `name` renames the theme, `settings` overwrites the stored settings.
185#[derive(Debug, Deserialize)]
186pub struct UpdateThemeRequest {
187    /// new display name, if renaming.
188    #[serde(default)]
189    pub name: Option<String>,
190    /// new settings, if overwriting.
191    #[serde(default)]
192    pub settings: Option<ThemeSettings>,
193}
194
195/// Parse a theme row's `settings_json` into [`ThemeSettings`].
196fn 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
203/// Build a [`ThemeView`] from a fetched row and the resolved creator
204/// display-name pair.
205fn 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
225/// Look up a user's display fields for view-building. Mirrors the helper
226/// in [`crate::routes::glw`].
227async 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
240/// Resolve the creator display-name pair for a row, swallowing a missing
241/// account into `(None, None)`.
242async 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/// Row shape returned by the listing query.
256#[derive(sqlx::FromRow)]
257struct ThemeListRow {
258    /// raw bytes of `themes.theme_id`.
259    theme_id: Vec<u8>,
260    /// raw bytes of `themes.owner_user_id`, if set.
261    owner_user_id: Option<Vec<u8>>,
262    /// raw bytes of `themes.owner_group_id`, if set.
263    owner_group_id: Option<Vec<u8>>,
264    /// raw bytes of the creating user's id, if still set.
265    created_by: Option<Vec<u8>>,
266    /// the creating user's `username`.
267    created_by_username: Option<String>,
268    /// the creating user's `legacy_name`.
269    created_by_legacy_name: Option<String>,
270    /// display name.
271    name: String,
272    /// the presentation settings as JSON.
273    settings_json: String,
274    /// row creation timestamp.
275    created_at: DateTime<Utc>,
276    /// last-modified timestamp.
277    updated_at: DateTime<Utc>,
278}
279
280/// `GET /api/themes?scope=…` — list themes in a scope. Personal lists the
281/// caller's own themes; a group lists that group's themes (visible to any
282/// member).
283///
284/// # Errors
285///
286/// Returns [`Error::BadRequest`] for an invalid scope; [`Error::Forbidden`]
287/// if the user is not allowed to view the scope.
288pub 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    // Both queries are built from string literals only; all user-supplied
297    // values are passed via bind parameters, so there is no injection risk.
298    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            // The JOIN against group_memberships enforces visibility at the
315            // SQL layer, matching the saved_glw_data list query.
316            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
372/// `POST /api/themes` — create a new theme in a scope. Personal scope is
373/// always allowed; group scope requires owner membership.
374///
375/// # Errors
376///
377/// Returns [`Error::BadRequest`] / [`Error::Forbidden`].
378pub 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
437/// `GET /api/themes/{id}` — fetch a single theme. Personal owner or any
438/// member of the owning group.
439///
440/// # Errors
441///
442/// Returns [`Error::NotFound`] if the theme doesn't exist or is invisible.
443pub 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
456/// `PATCH /api/themes/{id}` — rename and/or overwrite a theme's settings.
457/// Personal owner or group owner only.
458///
459/// # Errors
460///
461/// Returns [`Error::Forbidden`] / [`Error::NotFound`] / [`Error::BadRequest`].
462pub 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    // Reflect the applied changes back onto the row so the response matches
515    // what just landed in the DB without a redundant SELECT.
516    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
530/// `DELETE /api/themes/{id}` — delete a theme. Personal owner or group
531/// owner only. Nothing references a theme, so the delete always succeeds.
532///
533/// # Errors
534///
535/// Returns [`Error::Forbidden`] / [`Error::NotFound`].
536pub 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    /// A theme with every toggle off and no colours set — the baseline the
565    /// individual cases tweak one field at a time.
566    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()); // missing '#'
596        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()); // shorthand
603        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        // A payload from an older client that predates the version field.
629        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}