1use 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#[derive(Debug, Deserialize)]
28pub struct ListQuery {
29 pub scope: String,
31 #[serde(default)]
33 pub event_id: Option<u32>,
34 #[serde(default)]
36 pub event_key: Option<String>,
37}
38
39#[derive(Debug, Serialize)]
41pub struct ListGlwDataResponse {
42 pub glw_data: Vec<GlwDataView>,
44}
45
46#[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 pub glw_data: GlwDataView,
55}
56
57pub 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
82pub 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
114pub 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#[derive(Debug, Deserialize)]
146pub struct RenameRequest {
147 pub name: String,
149}
150
151pub 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 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
196pub 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#[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 pub margin_band: bool,
243 pub area_outline_color: String,
245 pub circle_outline_color: String,
247 pub margin_outline_color: String,
249 pub wind_color: String,
251 pub current_color: String,
253 pub wave_color: String,
255 pub label_color: String,
257}
258
259fn 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
269pub 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#[derive(Debug)]
291pub struct InsertGlwData<'a> {
292 pub destination: Destination,
294 pub created_by: Uuid,
296 pub name: &'a str,
298 pub source_kind: GlwDataSourceKind,
300 pub source_event_id: Option<u32>,
302 pub source_event_key: Option<&'a str>,
304 pub payload_json: &'a str,
306 pub event_id: Option<u32>,
308 pub event_key: Option<&'a str>,
310 pub event_name: Option<&'a str>,
312 pub fetched_at: DateTime<Utc>,
314}
315
316pub 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#[derive(sqlx::FromRow)]
365struct GlwDataListRow {
366 glw_data_id: Vec<u8>,
368 owner_user_id: Option<Vec<u8>>,
370 owner_group_id: Option<Vec<u8>>,
372 created_by: Option<Vec<u8>>,
376 created_by_username: Option<String>,
378 created_by_legacy_name: Option<String>,
380 name: String,
382 source_kind: String,
384 source_event_id: Option<i64>,
386 source_event_key: Option<String>,
388 event_id: Option<i64>,
390 event_key: Option<String>,
392 event_name: Option<String>,
394 fetched_at: DateTime<Utc>,
396 created_at: DateTime<Utc>,
398}
399
400async 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 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 let idx = 3_u8.saturating_add(u8::from(filter_event_id.is_some()));
437 write!(sql, "AND g.source_event_key = ?{idx} ").unwrap_or(());
443 }
444 sql.push_str("ORDER BY g.fetched_at DESC");
445
446 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
515fn 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
541async 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 #[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 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}