Skip to main content

sl_map_web/routes/
glw.rs

1//! HTTP handlers for saved GLW event data.
2//!
3//! The render worker is the primary writer here — every successful
4//! GLW-overlay render auto-inserts a `saved_glw_data` row through the
5//! shared [`insert_glw_data_row`] helper. The CRUD routes below let
6//! users list / inspect / rename / delete rows manually.
7
8use std::fmt::Write as _;
9
10use axum::Json;
11use axum::extract::{Path, Query, State};
12use axum::http::StatusCode as ReqwestStatusCode;
13use axum::http::header;
14use axum::response::{IntoResponse as _, Response};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::auth::{CurrentUser, uuid_from_bytes};
20use crate::error::{self, Error};
21use crate::library::{self, Destination, GlwDataRow, GlwDataSourceKind, GlwDataView};
22use crate::state::AppState;
23
24/// Query parameters for `GET /api/glw`. Both filters are optional and
25/// independently honoured; passing neither lists all rows the caller
26/// can see in `scope`.
27#[derive(Debug, Deserialize)]
28pub struct ListQuery {
29    /// `"personal"` or `"group:<uuid>"`.
30    pub scope: String,
31    /// Restrict to rows whose `source_event_id` matches this value.
32    #[serde(default)]
33    pub event_id: Option<u32>,
34    /// Restrict to rows whose `source_event_key` matches this value.
35    #[serde(default)]
36    pub event_key: Option<String>,
37}
38
39/// Response shape for `GET /api/glw`.
40#[derive(Debug, Serialize)]
41pub struct ListGlwDataResponse {
42    /// matching rows, newest fetched_at first.
43    pub glw_data: Vec<GlwDataView>,
44}
45
46/// Response shape for a single GLW row.
47#[expect(
48    clippy::module_name_repetitions,
49    reason = "matches the workspace convention: <Entity>Response in the <entity> route module"
50)]
51#[derive(Debug, Serialize)]
52pub struct GlwDataResponse {
53    /// the requested row.
54    pub glw_data: GlwDataView,
55}
56
57/// `GET /api/glw?scope=…&event_id=…&event_key=…` — list saved GLW
58/// rows in a scope, optionally filtered by source id or key.
59///
60/// # Errors
61///
62/// Returns [`Error::BadRequest`] for an invalid scope; [`Error::Forbidden`]
63/// if the user is not allowed to view the scope.
64pub async fn list(
65    user: CurrentUser,
66    State(state): State<AppState>,
67    Query(query): Query<ListQuery>,
68) -> Result<Json<ListGlwDataResponse>, Error> {
69    let destination = Destination::parse(&query.scope)?;
70    library::assert_can_view(&state.db, user.user_id, destination).await?;
71    let rows = fetch_glw_data_for(
72        &state,
73        user.user_id,
74        destination,
75        query.event_id,
76        query.event_key.as_deref(),
77    )
78    .await?;
79    Ok(Json(ListGlwDataResponse { glw_data: rows }))
80}
81
82/// `GET /api/glw/{id}` — fetch a single row's metadata (payload is
83/// returned by `GET /api/glw/{id}/payload` — currently inlined but a
84/// separate endpoint exists in case the payload grows large enough
85/// that we want to keep the list lean).
86///
87/// # Errors
88///
89/// Returns [`Error::NotFound`] if the row doesn't exist or is invisible.
90pub async fn get(
91    user: CurrentUser,
92    State(state): State<AppState>,
93    Path(glw_data_id): Path<Uuid>,
94) -> Result<Json<GlwDataResponse>, Error> {
95    let row = library::assert_can_read_glw_data(&state.db, user.user_id, glw_data_id).await?;
96    let destination =
97        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
98    let (created_by_username, created_by_legacy_name) = match row.created_by {
99        Some(id) => match lookup_user_names(&state, id).await {
100            Ok((u, l)) => (Some(u), Some(l)),
101            Err(_) => (None, None),
102        },
103        None => (None, None),
104    };
105    let view = build_view(
106        &row,
107        destination,
108        created_by_username,
109        created_by_legacy_name,
110    );
111    Ok(Json(GlwDataResponse { glw_data: view }))
112}
113
114/// `GET /api/glw/{id}/payload` — download the raw GLW event JSON
115/// payload as it was fetched / pasted. Served as an `application/json`
116/// attachment so the browser offers a "Save as" dialog, mirroring the
117/// notecard `/text` sub-route.
118///
119/// # Errors
120///
121/// Returns [`Error::NotFound`] if the row doesn't exist or is invisible.
122pub async fn payload(
123    user: CurrentUser,
124    State(state): State<AppState>,
125    Path(glw_data_id): Path<Uuid>,
126) -> Result<Response, Error> {
127    let row = library::assert_can_read_glw_data(&state.db, user.user_id, glw_data_id).await?;
128    let filename = format!(
129        "{}.json",
130        crate::routes::notecards::sanitise_for_filename(&row.name)
131            .unwrap_or_else(|| glw_data_id.to_string())
132    );
133    let disposition = format!("attachment; filename=\"{filename}\"");
134    let headers = [
135        (header::CONTENT_TYPE, "application/json".to_owned()),
136        (header::CONTENT_DISPOSITION, disposition),
137    ];
138    Ok((headers, row.payload_json).into_response())
139}
140
141/// Body for `PATCH /api/glw/{id}` — currently only `name` can be
142/// updated. Source / payload are immutable so that any render
143/// pointing at the row by `glw_data_id` still refers to the same
144/// underlying GLW event.
145#[derive(Debug, Deserialize)]
146pub struct RenameRequest {
147    /// new display name.
148    pub name: String,
149}
150
151/// `PATCH /api/glw/{id}` — rename a saved GLW row. Personal owner or
152/// group owner only.
153///
154/// # Errors
155///
156/// Returns [`Error::Forbidden`] / [`Error::NotFound`] / [`Error::BadRequest`].
157pub async fn rename(
158    user: CurrentUser,
159    State(state): State<AppState>,
160    Path(glw_data_id): Path<Uuid>,
161    Json(body): Json<RenameRequest>,
162) -> Result<Json<GlwDataResponse>, Error> {
163    let trimmed = library::sanitise_display_name(&body.name, "name")?;
164    let row = library::assert_can_delete_glw_data(&state.db, user.user_id, glw_data_id).await?;
165    sqlx::query("UPDATE saved_glw_data SET name = ?1 WHERE glw_data_id = ?2")
166        .bind(&trimmed)
167        .bind(glw_data_id.as_bytes().to_vec())
168        .execute(&state.db)
169        .await
170        .map_err(|err| {
171            tracing::error!("glw data rename failed: {err}");
172            Error::Database
173        })?;
174    let destination =
175        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
176    let (created_by_username, created_by_legacy_name) = match row.created_by {
177        Some(id) => match lookup_user_names(&state, id).await {
178            Ok((u, l)) => (Some(u), Some(l)),
179            Err(_) => (None, None),
180        },
181        None => (None, None),
182    };
183    // Build the view from the row plus the new name so the response
184    // reflects what just landed in the DB without a redundant SELECT.
185    let mut row_with_new_name = row;
186    row_with_new_name.name = trimmed;
187    let view = build_view(
188        &row_with_new_name,
189        destination,
190        created_by_username,
191        created_by_legacy_name,
192    );
193    Ok(Json(GlwDataResponse { glw_data: view }))
194}
195
196/// `DELETE /api/glw/{id}` — delete a saved GLW row. Personal owner or
197/// group owner only. The FK `saved_renders.glw_data_id` is `ON
198/// DELETE RESTRICT`, so a row referenced by any render fails with a
199/// human-friendly error pointing the user at the dependent renders.
200///
201/// # Errors
202///
203/// Returns [`Error::Forbidden`] / [`Error::NotFound`] / [`Error::BadRequest`].
204pub async fn delete(
205    user: CurrentUser,
206    State(state): State<AppState>,
207    Path(glw_data_id): Path<Uuid>,
208) -> Result<Response, Error> {
209    library::assert_can_delete_glw_data(&state.db, user.user_id, glw_data_id).await?;
210    let result = sqlx::query("DELETE FROM saved_glw_data WHERE glw_data_id = ?1")
211        .bind(glw_data_id.as_bytes().to_vec())
212        .execute(&state.db)
213        .await;
214    match result {
215        Ok(_) => Ok((ReqwestStatusCode::NO_CONTENT, "").into_response()),
216        Err(err) => {
217            if error::is_fk_violation(&err) {
218                return Err(Error::BadRequest(
219                    "cannot delete this GLW data: one or more saved renders still reference it. \
220                     Delete those renders first."
221                        .to_owned(),
222                ));
223            }
224            tracing::error!("glw data delete failed: {err}");
225            Err(Error::Database)
226        }
227    }
228}
229
230/// The default GLW style-override values, surfaced so the web form can
231/// pre-fill its colour swatches with the *actual* rendering defaults
232/// instead of the browser's black `#000000`. Field names mirror
233/// [`crate::routes::render::GlwStyleOverrides`] so the JS can map each
234/// entry straight onto its `<input type="color">`.
235#[expect(
236    clippy::module_name_repetitions,
237    reason = "matches the workspace convention: <Entity>Defaults/Response in the <entity> route module"
238)]
239#[derive(Debug, Serialize)]
240pub struct GlwStyleDefaults {
241    /// default for the "draw margin band" toggle.
242    pub margin_band: bool,
243    /// default area rectangle outline colour as `#rrggbb`.
244    pub area_outline_color: String,
245    /// default circle outline colour as `#rrggbb`.
246    pub circle_outline_color: String,
247    /// default dashed margin-band colour as `#rrggbb`.
248    pub margin_outline_color: String,
249    /// default filled wind-arrow colour as `#rrggbb`.
250    pub wind_color: String,
251    /// default filled current-arrow colour as `#rrggbb`.
252    pub current_color: String,
253    /// default wave-glyph colour as `#rrggbb`.
254    pub wave_color: String,
255    /// default per-shape label text colour as `#rrggbb`.
256    pub label_color: String,
257}
258
259/// Format the RGB channels of an [`image::Rgba<u8>`] as a `#rrggbb`
260/// string. The alpha channel is dropped because an HTML `<input
261/// type="color">` cannot represent it; the server re-applies the
262/// default's alpha whenever the swatch is left at its default (the
263/// frontend then sends no override for that field).
264fn rgb_hex(c: image::Rgba<u8>) -> String {
265    let [r, g, b, _a] = c.0;
266    format!("#{r:02x}{g:02x}{b:02x}")
267}
268
269/// `GET /api/glw/style-defaults` — the default GLW style-override
270/// values, derived from [`sl_glw::GlwStyle::default`] so the form can
271/// never drift from what the renderer actually draws.
272pub async fn style_defaults() -> Json<GlwStyleDefaults> {
273    let style = sl_glw::GlwStyle::default();
274    let p = style.palette;
275    Json(GlwStyleDefaults {
276        margin_band: style.draw_margin_band,
277        area_outline_color: rgb_hex(p.area_outline),
278        circle_outline_color: rgb_hex(p.circle_outline),
279        margin_outline_color: rgb_hex(p.margin_outline),
280        wind_color: rgb_hex(p.wind_arrow),
281        current_color: rgb_hex(p.current_arrow),
282        wave_color: rgb_hex(p.wave_glyph),
283        label_color: rgb_hex(p.label_fg),
284    })
285}
286
287/// Fields needed to persist a fresh `saved_glw_data` row. Shared by
288/// the render worker (via `insert_glw_data_row` below) and any future
289/// stand-alone POST handler.
290#[derive(Debug)]
291pub struct InsertGlwData<'a> {
292    /// destination scope (personal or a group).
293    pub destination: Destination,
294    /// the avatar saving the row.
295    pub created_by: Uuid,
296    /// the display name (caller-supplied or default).
297    pub name: &'a str,
298    /// where the event came from.
299    pub source_kind: GlwDataSourceKind,
300    /// originating numeric event id (only meaningful for `EventId`).
301    pub source_event_id: Option<u32>,
302    /// originating string event key (only meaningful for `EventKey`).
303    pub source_event_key: Option<&'a str>,
304    /// canonical JSON for the resolved event.
305    pub payload_json: &'a str,
306    /// numeric event id from the resolved JSON.
307    pub event_id: Option<u32>,
308    /// string event key from the resolved JSON.
309    pub event_key: Option<&'a str>,
310    /// human-readable event name from the resolved JSON.
311    pub event_name: Option<&'a str>,
312    /// when the event was originally fetched / pasted.
313    pub fetched_at: DateTime<Utc>,
314}
315
316/// Persist a fresh `saved_glw_data` row and return its id. Callers
317/// must already have permission to write to `destination`.
318///
319/// # Errors
320///
321/// Returns [`Error::Database`] on any underlying SQLite failure.
322pub async fn insert_glw_data_row(
323    state: &AppState,
324    insert: &InsertGlwData<'_>,
325) -> Result<Uuid, Error> {
326    let glw_data_id = Uuid::new_v4();
327    let now = Utc::now();
328    let (owner_user, owner_group) = match insert.destination {
329        Destination::Personal => (Some(insert.created_by.as_bytes().to_vec()), None),
330        Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
331    };
332    sqlx::query(
333        "INSERT INTO saved_glw_data \
334            (glw_data_id, owner_user_id, owner_group_id, created_by, name, \
335             source_kind, source_event_id, source_event_key, payload_json, \
336             event_id, event_key, event_name, fetched_at, created_at) \
337         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
338    )
339    .bind(glw_data_id.as_bytes().to_vec())
340    .bind(owner_user)
341    .bind(owner_group)
342    .bind(insert.created_by.as_bytes().to_vec())
343    .bind(insert.name)
344    .bind(insert.source_kind.as_db_str())
345    .bind(insert.source_event_id.map(i64::from))
346    .bind(insert.source_event_key)
347    .bind(insert.payload_json)
348    .bind(insert.event_id.map(i64::from))
349    .bind(insert.event_key)
350    .bind(insert.event_name)
351    .bind(insert.fetched_at)
352    .bind(now)
353    .execute(&state.db)
354    .await
355    .map_err(|err| {
356        tracing::error!("insert saved_glw_data failed: {err}");
357        Error::Database
358    })?;
359    Ok(glw_data_id)
360}
361
362/// Row shape returned by the listing query (uses a `FromRow` struct
363/// because the column count is at sqlx's tuple-arity limit).
364#[derive(sqlx::FromRow)]
365struct GlwDataListRow {
366    /// raw bytes of `saved_glw_data.glw_data_id`.
367    glw_data_id: Vec<u8>,
368    /// raw bytes of `saved_glw_data.owner_user_id`, if set.
369    owner_user_id: Option<Vec<u8>>,
370    /// raw bytes of `saved_glw_data.owner_group_id`, if set.
371    owner_group_id: Option<Vec<u8>>,
372    /// raw bytes of the creating user's id. `None` only if the
373    /// account has been deleted (kept here for forward-compatibility
374    /// — the column is currently NOT NULL).
375    created_by: Option<Vec<u8>>,
376    /// the creating user's `username`.
377    created_by_username: Option<String>,
378    /// the creating user's `legacy_name`.
379    created_by_legacy_name: Option<String>,
380    /// display name.
381    name: String,
382    /// the raw `source_kind` text column.
383    source_kind: String,
384    /// originating numeric event id, when applicable.
385    source_event_id: Option<i64>,
386    /// originating string event key, when applicable.
387    source_event_key: Option<String>,
388    /// numeric event id from the resolved JSON.
389    event_id: Option<i64>,
390    /// string event key from the resolved JSON.
391    event_key: Option<String>,
392    /// human-readable event name from the resolved JSON.
393    event_name: Option<String>,
394    /// when the event was fetched / pasted.
395    fetched_at: DateTime<Utc>,
396    /// row creation timestamp.
397    created_at: DateTime<Utc>,
398}
399
400/// Run the list query for the given scope and optional source filters.
401async fn fetch_glw_data_for(
402    state: &AppState,
403    current_user: Uuid,
404    destination: Destination,
405    filter_event_id: Option<u32>,
406    filter_event_key: Option<&str>,
407) -> Result<Vec<GlwDataView>, Error> {
408    let mut sql = String::from(
409        "SELECT g.glw_data_id, g.owner_user_id, g.owner_group_id, g.created_by, \
410                u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
411                g.name, g.source_kind, g.source_event_id, g.source_event_key, \
412                g.event_id, g.event_key, g.event_name, g.fetched_at, g.created_at \
413         FROM saved_glw_data AS g \
414         LEFT JOIN users AS u ON u.user_id = g.created_by ",
415    );
416    match destination {
417        Destination::Personal => {
418            sql.push_str("WHERE g.owner_user_id = ?1 ");
419        }
420        Destination::Group { .. } => {
421            // The JOIN against group_memberships enforces visibility at
422            // the SQL layer, matching the saved_notecards list query.
423            sql.push_str(
424                "JOIN group_memberships AS gm \
425                   ON gm.group_id = g.owner_group_id AND gm.user_id = ?2 \
426                 WHERE g.owner_group_id = ?1 ",
427            );
428        }
429    }
430    if filter_event_id.is_some() {
431        sql.push_str("AND g.source_event_id = ?3 ");
432    }
433    if filter_event_key.is_some() {
434        // Bind index depends on whether event_id is also bound; build
435        // a fresh placeholder index up front.
436        let idx = 3_u8.saturating_add(u8::from(filter_event_id.is_some()));
437        // Writing directly into the SQL buffer avoids an intermediate
438        // allocation that the `clippy::format-push-string` lint flags.
439        // `String::write_fmt` is infallible; `unwrap_or(())` discards
440        // the unreachable `Err` arm without tripping the banned-method
441        // lints (`unwrap()` / `expect()` / `panic!`).
442        write!(sql, "AND g.source_event_key = ?{idx} ").unwrap_or(());
443    }
444    sql.push_str("ORDER BY g.fetched_at DESC");
445
446    // The SQL is assembled from string literals only; all user-supplied
447    // values are passed via bind parameters below, so the assertion holds.
448    let mut query = sqlx::query_as::<_, GlwDataListRow>(sqlx::AssertSqlSafe(sql));
449    match destination {
450        Destination::Personal => {
451            query = query.bind(current_user.as_bytes().to_vec());
452        }
453        Destination::Group { group_id } => {
454            query = query
455                .bind(group_id.as_bytes().to_vec())
456                .bind(current_user.as_bytes().to_vec());
457        }
458    }
459    if let Some(id) = filter_event_id {
460        query = query.bind(i64::from(id));
461    }
462    if let Some(key) = filter_event_key {
463        query = query.bind(key);
464    }
465    let rows: Vec<GlwDataListRow> = query.fetch_all(&state.db).await.map_err(|err| {
466        tracing::error!("list saved_glw_data failed: {err}");
467        Error::Database
468    })?;
469
470    let mut out = Vec::with_capacity(rows.len());
471    for row in rows {
472        let glw_data_id = uuid_from_bytes(&row.glw_data_id).ok_or_else(|| {
473            tracing::error!("bad glw_data uuid");
474            Error::Database
475        })?;
476        let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
477        let created_by = row
478            .created_by
479            .as_deref()
480            .map(uuid_from_bytes)
481            .map(|opt| {
482                opt.ok_or_else(|| {
483                    tracing::error!("bad created_by uuid in saved_glw_data");
484                    Error::Database
485                })
486            })
487            .transpose()?;
488        let source_kind = GlwDataSourceKind::from_db_str(&row.source_kind).ok_or_else(|| {
489            tracing::error!(
490                "unrecognised source_kind `{}` in saved_glw_data",
491                row.source_kind
492            );
493            Error::Database
494        })?;
495        out.push(GlwDataView {
496            glw_data_id,
497            destination: row_dest,
498            created_by,
499            created_by_username: row.created_by_username,
500            created_by_legacy_name: row.created_by_legacy_name,
501            name: row.name,
502            source_kind,
503            source_event_id: row.source_event_id.and_then(|v| u32::try_from(v).ok()),
504            source_event_key: row.source_event_key,
505            event_id: row.event_id.and_then(|v| u32::try_from(v).ok()),
506            event_key: row.event_key,
507            event_name: row.event_name,
508            fetched_at: row.fetched_at,
509            created_at: row.created_at,
510        });
511    }
512    Ok(out)
513}
514
515/// Build a [`GlwDataView`] from a fetched row and the resolved creator
516/// display name pair.
517fn build_view(
518    row: &GlwDataRow,
519    destination: Destination,
520    created_by_username: Option<String>,
521    created_by_legacy_name: Option<String>,
522) -> GlwDataView {
523    GlwDataView {
524        glw_data_id: row.glw_data_id,
525        destination,
526        created_by: row.created_by,
527        created_by_username,
528        created_by_legacy_name,
529        name: row.name.clone(),
530        source_kind: row.source_kind,
531        source_event_id: row.source_event_id,
532        source_event_key: row.source_event_key.clone(),
533        event_id: row.event_id,
534        event_key: row.event_key.clone(),
535        event_name: row.event_name.clone(),
536        fetched_at: row.fetched_at,
537        created_at: row.created_at,
538    }
539}
540
541/// Look up a user's display fields for view-building. Mirrors the
542/// helper in [`crate::routes::notecards`].
543async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
544    let row: Option<(String, String)> =
545        sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
546            .bind(user_id.as_bytes().to_vec())
547            .fetch_optional(&state.db)
548            .await
549            .map_err(|err| {
550                tracing::error!("user name lookup failed: {err}");
551                Error::Database
552            })?;
553    row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
554}
555
556#[cfg(test)]
557mod tests {
558    use pretty_assertions::assert_eq;
559
560    use super::{rgb_hex, style_defaults};
561
562    #[test]
563    fn rgb_hex_drops_alpha_and_zero_pads() {
564        assert_eq!(rgb_hex(image::Rgba([40, 220, 40, 96])), "#28dc28");
565        assert_eq!(rgb_hex(image::Rgba([0, 0, 0, 255])), "#000000");
566        assert_eq!(rgb_hex(image::Rgba([255, 255, 255, 240])), "#ffffff");
567    }
568
569    /// The exposed defaults must stay in lock-step with the renderer's
570    /// [`sl_glw::GlwStyle::default`] palette — this is the whole point of
571    /// the endpoint. Derive the expectations from the same source so the
572    /// test fails loudly if the palette default ever changes.
573    #[tokio::test]
574    async fn style_defaults_match_renderer_palette() {
575        let style = sl_glw::GlwStyle::default();
576        let p = style.palette;
577        let got = style_defaults().await.0;
578
579        assert_eq!(got.margin_band, style.draw_margin_band);
580        assert_eq!(got.area_outline_color, rgb_hex(p.area_outline));
581        assert_eq!(got.circle_outline_color, rgb_hex(p.circle_outline));
582        assert_eq!(got.margin_outline_color, rgb_hex(p.margin_outline));
583        assert_eq!(got.wind_color, rgb_hex(p.wind_arrow));
584        assert_eq!(got.current_color, rgb_hex(p.current_arrow));
585        assert_eq!(got.wave_color, rgb_hex(p.wave_glyph));
586        assert_eq!(got.label_color, rgb_hex(p.label_fg));
587
588        // Guard the user-visible expectations explicitly: arrows white,
589        // outlines green, label text white, margin band off.
590        assert_eq!(got.wind_color, "#ffffff");
591        assert_eq!(got.area_outline_color, "#28dc28");
592        assert_eq!(got.label_color, "#ffffff");
593        assert!(!got.margin_band);
594    }
595}