1use std::collections::HashSet;
9use std::path::Path;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Duration;
13
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use sqlx::SqlitePool;
17use tokio::time::interval;
18use uuid::Uuid;
19
20use crate::auth::uuid_from_bytes;
21use crate::error::Error;
22use crate::groups::{self, GroupRole};
23use crate::storage;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(tag = "kind", rename_all = "lowercase")]
29pub enum Destination {
30 Personal,
32 Group {
34 group_id: Uuid,
36 },
37}
38
39impl Destination {
40 pub fn parse(raw: &str) -> Result<Self, Error> {
47 let trimmed = raw.trim();
48 if trimmed.eq_ignore_ascii_case("personal") {
49 return Ok(Self::Personal);
50 }
51 if let Some(rest) = trimmed
52 .strip_prefix("group:")
53 .or_else(|| trimmed.strip_prefix("Group:"))
54 {
55 let group_id = Uuid::parse_str(rest.trim()).map_err(|err| {
56 Error::BadRequest(format!("invalid group uuid in destination: {err}"))
57 })?;
58 return Ok(Self::Group { group_id });
59 }
60 Err(Error::BadRequest(format!(
61 "destination must be `personal` or `group:<uuid>`, got `{trimmed}`"
62 )))
63 }
64
65 #[must_use]
68 pub fn render_string(self) -> String {
69 match self {
70 Self::Personal => "personal".to_owned(),
71 Self::Group { group_id } => format!("group:{group_id}"),
72 }
73 }
74}
75
76pub const MAX_DISPLAY_NAME_LEN: usize = 128;
79
80const fn is_unicode_format(c: char) -> bool {
87 matches!(
88 c,
89 '\u{00AD}'
90 | '\u{0600}'..='\u{0605}'
91 | '\u{061C}'
92 | '\u{06DD}'
93 | '\u{070F}'
94 | '\u{0890}'..='\u{0891}'
95 | '\u{08E2}'
96 | '\u{180E}'
97 | '\u{200B}'..='\u{200F}'
98 | '\u{202A}'..='\u{202E}'
99 | '\u{2060}'..='\u{2064}'
100 | '\u{2066}'..='\u{206F}'
101 | '\u{FEFF}'
102 | '\u{FFF9}'..='\u{FFFB}'
103 | '\u{110BD}'
104 | '\u{110CD}'
105 | '\u{13430}'..='\u{1343F}'
106 | '\u{1BCA0}'..='\u{1BCA3}'
107 | '\u{1D173}'..='\u{1D17A}'
108 | '\u{E0001}'
109 | '\u{E0020}'..='\u{E007F}'
110 )
111}
112
113pub fn sanitise_display_name(raw: &str, field: &str) -> Result<String, Error> {
125 let trimmed = raw.trim();
126 if trimmed.is_empty() {
127 return Err(Error::BadRequest(format!("{field} must not be empty")));
128 }
129 if trimmed
130 .chars()
131 .any(|c| c.is_control() || is_unicode_format(c))
132 {
133 return Err(Error::BadRequest(format!(
134 "{field} must not contain control or formatting characters"
135 )));
136 }
137 if trimmed.chars().count() > MAX_DISPLAY_NAME_LEN {
138 return Err(Error::BadRequest(format!(
139 "{field} must be at most {MAX_DISPLAY_NAME_LEN} characters"
140 )));
141 }
142 Ok(trimmed.to_owned())
143}
144
145#[must_use]
150pub fn is_canonical_hex_color(s: &str) -> bool {
151 let mut chars = s.chars();
152 if chars.next() != Some('#') {
153 return false;
154 }
155 let mut count = 0_usize;
156 for c in chars {
157 if !c.is_ascii_hexdigit() {
158 return false;
159 }
160 count = count.saturating_add(1);
161 }
162 count == 6
163}
164
165#[derive(Debug, Clone, Serialize)]
167pub struct NotecardView {
168 pub notecard_id: Uuid,
170 pub destination: Destination,
172 pub uploaded_by: Option<Uuid>,
177 pub uploaded_by_username: Option<String>,
179 pub uploaded_by_legacy_name: Option<String>,
181 pub name: String,
183 pub created_at: DateTime<Utc>,
185 pub start_region: Option<String>,
188 pub end_region: Option<String>,
191 pub waypoint_count: Option<u32>,
193 pub lower_left_x: Option<u16>,
196 pub lower_left_y: Option<u16>,
198 pub upper_right_x: Option<u16>,
200 pub upper_right_y: Option<u16>,
202}
203
204#[derive(Debug, Clone, Serialize)]
206pub struct RenderView {
207 pub render_id: Uuid,
209 pub destination: Destination,
211 pub created_by: Option<Uuid>,
214 pub created_by_username: Option<String>,
216 pub created_by_legacy_name: Option<String>,
218 pub notecard_id: Option<Uuid>,
220 pub notecard_name: Option<String>,
226 pub kind: String,
228 pub status: String,
230 pub error_message: Option<String>,
232 pub created_at: DateTime<Utc>,
234 pub finished_at: Option<DateTime<Utc>>,
236 pub has_without_route: bool,
238 pub content_type: Option<String>,
240 pub lower_left_x: Option<u16>,
244 pub lower_left_y: Option<u16>,
246 pub upper_right_x: Option<u16>,
248 pub upper_right_y: Option<u16>,
250 pub glw_data_id: Option<Uuid>,
256 pub glw_data_name: Option<String>,
259}
260
261pub async fn assert_can_write(
270 db: &SqlitePool,
271 current_user: Uuid,
272 destination: Destination,
273) -> Result<(), Error> {
274 match destination {
275 Destination::Personal => Ok(()),
276 Destination::Group { group_id } => {
277 groups::require_exists(db, group_id).await?;
278 let role = groups::lookup_role(db, group_id, current_user).await?;
279 if role == Some(GroupRole::Owner) {
280 Ok(())
281 } else {
282 Err(Error::Forbidden(format!(
283 "must be an owner of group {group_id} to save items there"
284 )))
285 }
286 }
287 }
288}
289
290pub async fn assert_can_view(
299 db: &SqlitePool,
300 current_user: Uuid,
301 destination: Destination,
302) -> Result<Option<GroupRole>, Error> {
303 match destination {
304 Destination::Personal => Ok(None),
305 Destination::Group { group_id } => {
306 groups::require_exists(db, group_id).await?;
307 let role = groups::lookup_role(db, group_id, current_user).await?;
308 role.map_or_else(
309 || {
310 Err(Error::Forbidden(format!(
311 "not a member of group {group_id}"
312 )))
313 },
314 |r| Ok(Some(r)),
315 )
316 }
317 }
318}
319
320pub fn destination_from_columns(
328 owner_user_id: Option<Vec<u8>>,
329 owner_group_id: Option<Vec<u8>>,
330) -> Result<Destination, Error> {
331 match (owner_user_id, owner_group_id) {
332 (Some(_), None) => Ok(Destination::Personal),
333 (None, Some(gid_bytes)) => {
334 let group_id = uuid_from_bytes(&gid_bytes).ok_or_else(|| {
335 tracing::error!("bad group uuid blob in destination column");
336 Error::Database
337 })?;
338 Ok(Destination::Group { group_id })
339 }
340 _ => {
341 tracing::error!("saved row had both or neither owner column set");
342 Err(Error::Database)
343 }
344 }
345}
346
347pub async fn assert_can_read_notecard(
356 db: &SqlitePool,
357 current_user: Uuid,
358 notecard_id: Uuid,
359) -> Result<NotecardRow, Error> {
360 let row = fetch_notecard_row(db, notecard_id).await?;
361 let destination =
362 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
363 let visible = match destination {
364 Destination::Personal => {
365 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
366 }
367 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
368 .await?
369 .is_some(),
370 };
371 if visible {
372 Ok(row)
373 } else {
374 Err(Error::NotFound(format!("notecard {notecard_id}")))
375 }
376}
377
378pub async fn assert_can_read_render(
388 db: &SqlitePool,
389 current_user: Uuid,
390 render_id: Uuid,
391) -> Result<RenderRow, Error> {
392 let row = fetch_render_row(db, render_id).await?;
393 let destination =
394 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
395 let visible = match destination {
396 Destination::Personal => {
397 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
398 }
399 Destination::Group { group_id } => {
400 match groups::lookup_role(db, group_id, current_user).await? {
401 Some(GroupRole::Owner) => true,
402 Some(GroupRole::Member) => row.status == "done",
403 None => false,
404 }
405 }
406 };
407 if visible {
408 Ok(row)
409 } else {
410 Err(Error::NotFound(format!("render {render_id}")))
411 }
412}
413
414pub async fn assert_can_delete_render(
422 db: &SqlitePool,
423 current_user: Uuid,
424 render_id: Uuid,
425) -> Result<RenderRow, Error> {
426 let row = fetch_render_row(db, render_id).await?;
427 let destination =
428 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
429 match destination {
430 Destination::Personal => {
431 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
432 if owner == Some(current_user) {
433 Ok(row)
434 } else {
435 Err(Error::Forbidden(format!(
436 "not allowed to delete render {render_id}"
437 )))
438 }
439 }
440 Destination::Group { group_id } => {
441 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
442 Ok(row)
443 } else {
444 Err(Error::Forbidden(
445 "must be a group owner to delete a group render".to_owned(),
446 ))
447 }
448 }
449 }
450}
451
452pub async fn assert_can_delete_notecard(
459 db: &SqlitePool,
460 current_user: Uuid,
461 notecard_id: Uuid,
462) -> Result<NotecardRow, Error> {
463 let row = fetch_notecard_row(db, notecard_id).await?;
464 let destination =
465 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
466 match destination {
467 Destination::Personal => {
468 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
469 if owner == Some(current_user) {
470 Ok(row)
471 } else {
472 Err(Error::Forbidden(format!(
473 "not allowed to delete notecard {notecard_id}"
474 )))
475 }
476 }
477 Destination::Group { group_id } => {
478 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
479 Ok(row)
480 } else {
481 Err(Error::Forbidden(
482 "must be a group owner to delete a group notecard".to_owned(),
483 ))
484 }
485 }
486 }
487}
488
489#[derive(Debug, Clone)]
491pub struct NotecardRow {
492 pub notecard_id: Uuid,
494 pub owner_user_id: Option<Vec<u8>>,
496 pub owner_group_id: Option<Vec<u8>>,
498 pub uploaded_by: Option<Uuid>,
501 pub name: String,
503 pub body: String,
505 pub created_at: DateTime<Utc>,
507 pub lower_left_x: Option<u16>,
510 pub lower_left_y: Option<u16>,
512 pub upper_right_x: Option<u16>,
514 pub upper_right_y: Option<u16>,
516}
517
518#[derive(Debug, Clone)]
520pub struct RenderRow {
521 pub render_id: Uuid,
523 pub owner_user_id: Option<Vec<u8>>,
525 pub owner_group_id: Option<Vec<u8>>,
527 pub created_by: Option<Uuid>,
530 pub notecard_id: Option<Uuid>,
532 pub kind: String,
534 pub status: String,
536 pub error_message: Option<String>,
538 pub settings_json: String,
540 pub metadata_json: Option<String>,
542 pub content_type: Option<String>,
544 pub image_filename: Option<String>,
546 pub image_without_route_filename: Option<String>,
548 pub created_at: DateTime<Utc>,
550 pub finished_at: Option<DateTime<Utc>>,
552 pub lower_left_x: Option<u16>,
554 pub lower_left_y: Option<u16>,
556 pub upper_right_x: Option<u16>,
558 pub upper_right_y: Option<u16>,
560 pub glw_data_id: Option<Uuid>,
562}
563
564type NotecardRowTuple = (
566 Option<Vec<u8>>,
567 Option<Vec<u8>>,
568 Option<Vec<u8>>,
569 String,
570 String,
571 DateTime<Utc>,
572 Option<i64>,
573 Option<i64>,
574 Option<i64>,
575 Option<i64>,
576);
577
578async fn fetch_notecard_row(db: &SqlitePool, notecard_id: Uuid) -> Result<NotecardRow, Error> {
580 let row: Option<NotecardRowTuple> = sqlx::query_as(
581 "SELECT owner_user_id, owner_group_id, uploaded_by, name, body, created_at, \
582 lower_left_x, lower_left_y, upper_right_x, upper_right_y \
583 FROM saved_notecards WHERE notecard_id = ?1",
584 )
585 .bind(notecard_id.as_bytes().to_vec())
586 .fetch_optional(db)
587 .await
588 .map_err(|err| {
589 tracing::error!("notecard fetch failed: {err}");
590 Error::Database
591 })?;
592 let (
593 owner_user_id,
594 owner_group_id,
595 uploaded_by_bytes,
596 name,
597 body,
598 created_at,
599 lower_left_x,
600 lower_left_y,
601 upper_right_x,
602 upper_right_y,
603 ) = row.ok_or_else(|| Error::NotFound(format!("notecard {notecard_id}")))?;
604 let uploaded_by = uploaded_by_bytes
605 .as_deref()
606 .map(uuid_from_bytes)
607 .map(|opt| {
608 opt.ok_or_else(|| {
609 tracing::error!("bad uploaded_by uuid in saved_notecards");
610 Error::Database
611 })
612 })
613 .transpose()?;
614 Ok(NotecardRow {
615 notecard_id,
616 owner_user_id,
617 owner_group_id,
618 uploaded_by,
619 name,
620 body,
621 created_at,
622 lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
623 lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
624 upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
625 upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
626 })
627}
628
629#[derive(sqlx::FromRow)]
633struct RenderRowDb {
634 owner_user_id: Option<Vec<u8>>,
636 owner_group_id: Option<Vec<u8>>,
638 created_by: Option<Vec<u8>>,
641 notecard_id: Option<Vec<u8>>,
643 kind: String,
645 status: String,
647 error_message: Option<String>,
649 settings_json: String,
651 metadata_json: Option<String>,
653 content_type: Option<String>,
655 image_filename: Option<String>,
657 image_without_route_filename: Option<String>,
659 created_at: DateTime<Utc>,
661 finished_at: Option<DateTime<Utc>>,
663 lower_left_x: Option<i64>,
665 lower_left_y: Option<i64>,
667 upper_right_x: Option<i64>,
669 upper_right_y: Option<i64>,
671 glw_data_id: Option<Vec<u8>>,
673}
674
675async fn fetch_render_row(db: &SqlitePool, render_id: Uuid) -> Result<RenderRow, Error> {
677 let row: Option<RenderRowDb> = sqlx::query_as(
678 "SELECT owner_user_id, owner_group_id, created_by, notecard_id, kind, status, \
679 error_message, settings_json, metadata_json, content_type, \
680 image_filename, image_without_route_filename, created_at, finished_at, \
681 lower_left_x, lower_left_y, upper_right_x, upper_right_y, glw_data_id \
682 FROM saved_renders WHERE render_id = ?1",
683 )
684 .bind(render_id.as_bytes().to_vec())
685 .fetch_optional(db)
686 .await
687 .map_err(|err| {
688 tracing::error!("render fetch failed: {err}");
689 Error::Database
690 })?;
691 let RenderRowDb {
692 owner_user_id,
693 owner_group_id,
694 created_by: created_by_bytes,
695 notecard_id: notecard_bytes,
696 kind,
697 status,
698 error_message,
699 settings_json,
700 metadata_json,
701 content_type,
702 image_filename,
703 image_without_route_filename,
704 created_at,
705 finished_at,
706 lower_left_x,
707 lower_left_y,
708 upper_right_x,
709 upper_right_y,
710 glw_data_id: glw_data_id_bytes,
711 } = row.ok_or_else(|| Error::NotFound(format!("render {render_id}")))?;
712 let glw_data_id = glw_data_id_bytes
713 .as_deref()
714 .map(uuid_from_bytes)
715 .map(|opt| {
716 opt.ok_or_else(|| {
717 tracing::error!("bad glw_data_id uuid in saved_renders");
718 Error::Database
719 })
720 })
721 .transpose()?;
722 let created_by = created_by_bytes
723 .as_deref()
724 .map(uuid_from_bytes)
725 .map(|opt| {
726 opt.ok_or_else(|| {
727 tracing::error!("bad created_by uuid in saved_renders");
728 Error::Database
729 })
730 })
731 .transpose()?;
732 let notecard_id = notecard_bytes
733 .as_deref()
734 .map(uuid_from_bytes)
735 .map(|opt| {
736 opt.ok_or_else(|| {
737 tracing::error!("bad notecard_id uuid in saved_renders");
738 Error::Database
739 })
740 })
741 .transpose()?;
742 Ok(RenderRow {
743 render_id,
744 owner_user_id,
745 owner_group_id,
746 created_by,
747 notecard_id,
748 kind,
749 status,
750 error_message,
751 settings_json,
752 metadata_json,
753 content_type,
754 image_filename,
755 image_without_route_filename,
756 created_at,
757 finished_at,
758 lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
759 lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
760 upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
761 upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
762 glw_data_id,
763 })
764}
765
766pub async fn recover_orphaned_in_progress(pool: &SqlitePool) -> Result<u64, Error> {
785 let now = Utc::now();
786 let result = sqlx::query(
787 "UPDATE saved_renders \
788 SET status = 'failed', finished_at = ?1, \
789 error_message = 'server restarted before render completed' \
790 WHERE status = 'in_progress'",
791 )
792 .bind(now)
793 .execute(pool)
794 .await
795 .map_err(|err| {
796 tracing::error!("recover orphaned in_progress renders failed: {err}");
797 Error::Database
798 })?;
799 Ok(result.rows_affected())
800}
801
802pub async fn run_orphan_sweeper(
808 db: SqlitePool,
809 storage_dir: Arc<Path>,
810 dirty: Arc<AtomicBool>,
811 period: Duration,
812) {
813 let mut tick = interval(period);
814 loop {
815 tick.tick().await;
816 if !dirty.swap(false, Ordering::AcqRel) {
817 tracing::debug!("orphan sweeper: no work flagged, skipping");
818 continue;
819 }
820 match sweep_once(&db, storage_dir.as_ref()).await {
821 Ok(count) => {
822 if count > 0 {
823 tracing::info!("orphan sweeper: removed {count} stale render file(s)");
824 }
825 }
826 Err(err) => {
827 tracing::warn!("orphan sweeper run failed: {err}; will retry on next tick");
828 dirty.store(true, Ordering::Release);
829 }
830 }
831 }
832}
833
834async fn sweep_once(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
838 let renders = sweep_renders(db, storage_dir).await?;
839 let logos = sweep_logos(db, storage_dir).await?;
840 Ok(renders.saturating_add(logos))
841}
842
843async fn sweep_renders(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
845 let files = storage::list_render_files(storage_dir)?;
846 let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT render_id FROM saved_renders")
847 .fetch_all(db)
848 .await
849 .map_err(|err| {
850 tracing::error!("sweeper render id query failed: {err}");
851 Error::Database
852 })?;
853 let live_set: HashSet<Uuid> = live
854 .into_iter()
855 .filter_map(|b| uuid_from_bytes(&b))
856 .collect();
857 let mut removed = 0_usize;
858 for filename in files {
859 let Some(id) = storage::parse_render_id_from_filename(&filename) else {
860 continue;
861 };
862 if live_set.contains(&id) {
863 continue;
864 }
865 if let Err(err) = storage::try_delete_render_file(storage_dir, &filename) {
866 tracing::warn!("sweeper failed to unlink {filename}: {err}");
867 continue;
868 }
869 removed = removed.saturating_add(1);
870 }
871 Ok(removed)
872}
873
874async fn sweep_logos(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
876 let files = storage::list_logo_files(storage_dir)?;
877 let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT logo_id FROM saved_logos")
878 .fetch_all(db)
879 .await
880 .map_err(|err| {
881 tracing::error!("sweeper logo id query failed: {err}");
882 Error::Database
883 })?;
884 let live_set: HashSet<Uuid> = live
885 .into_iter()
886 .filter_map(|b| uuid_from_bytes(&b))
887 .collect();
888 let mut removed = 0_usize;
889 for filename in files {
890 let Some(id) = storage::parse_logo_id_from_filename(&filename) else {
891 continue;
892 };
893 if live_set.contains(&id) {
894 continue;
895 }
896 if let Err(err) = storage::try_delete_logo_file(storage_dir, &filename) {
897 tracing::warn!("sweeper failed to unlink {filename}: {err}");
898 continue;
899 }
900 removed = removed.saturating_add(1);
901 }
902 Ok(removed)
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
919#[serde(rename_all = "snake_case")]
920pub enum GlwDataSourceKind {
921 EventId,
923 EventKey,
925 PastedJson,
927}
928
929impl GlwDataSourceKind {
930 #[must_use]
932 pub const fn as_db_str(self) -> &'static str {
933 match self {
934 Self::EventId => "event_id",
935 Self::EventKey => "event_key",
936 Self::PastedJson => "pasted_json",
937 }
938 }
939
940 #[must_use]
943 pub fn from_db_str(s: &str) -> Option<Self> {
944 match s {
945 "event_id" => Some(Self::EventId),
946 "event_key" => Some(Self::EventKey),
947 "pasted_json" => Some(Self::PastedJson),
948 _ => None,
949 }
950 }
951}
952
953#[derive(Debug, Clone)]
955pub struct GlwDataRow {
956 pub glw_data_id: Uuid,
958 pub owner_user_id: Option<Vec<u8>>,
960 pub owner_group_id: Option<Vec<u8>>,
962 pub created_by: Option<Uuid>,
964 pub name: String,
966 pub source_kind: GlwDataSourceKind,
968 pub source_event_id: Option<u32>,
970 pub source_event_key: Option<String>,
972 pub payload_json: String,
975 pub event_id: Option<u32>,
977 pub event_key: Option<String>,
979 pub event_name: Option<String>,
981 pub fetched_at: DateTime<Utc>,
983 pub created_at: DateTime<Utc>,
985}
986
987#[derive(Debug, Clone, Serialize)]
991pub struct GlwDataView {
992 pub glw_data_id: Uuid,
994 pub destination: Destination,
996 pub created_by: Option<Uuid>,
999 pub created_by_username: Option<String>,
1001 pub created_by_legacy_name: Option<String>,
1003 pub name: String,
1005 pub source_kind: GlwDataSourceKind,
1007 pub source_event_id: Option<u32>,
1009 pub source_event_key: Option<String>,
1011 pub event_id: Option<u32>,
1013 pub event_key: Option<String>,
1015 pub event_name: Option<String>,
1017 pub fetched_at: DateTime<Utc>,
1019 pub created_at: DateTime<Utc>,
1021}
1022
1023type GlwDataRowTuple = (
1026 Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, Option<i64>, Option<String>, String, Option<i64>, Option<String>, Option<String>, DateTime<Utc>, DateTime<Utc>, );
1040
1041async fn fetch_glw_data_row(db: &SqlitePool, glw_data_id: Uuid) -> Result<GlwDataRow, Error> {
1043 let row: Option<GlwDataRowTuple> = sqlx::query_as(
1044 "SELECT owner_user_id, owner_group_id, created_by, name, source_kind, \
1045 source_event_id, source_event_key, payload_json, \
1046 event_id, event_key, event_name, fetched_at, created_at \
1047 FROM saved_glw_data WHERE glw_data_id = ?1",
1048 )
1049 .bind(glw_data_id.as_bytes().to_vec())
1050 .fetch_optional(db)
1051 .await
1052 .map_err(|err| {
1053 tracing::error!("GLW data fetch failed: {err}");
1054 Error::Database
1055 })?;
1056 let (
1057 owner_user_id,
1058 owner_group_id,
1059 created_by_bytes,
1060 name,
1061 source_kind_str,
1062 source_event_id,
1063 source_event_key,
1064 payload_json,
1065 event_id,
1066 event_key,
1067 event_name,
1068 fetched_at,
1069 created_at,
1070 ) = row.ok_or_else(|| Error::NotFound(format!("glw data {glw_data_id}")))?;
1071 let created_by = created_by_bytes
1072 .as_deref()
1073 .map(uuid_from_bytes)
1074 .map(|opt| {
1075 opt.ok_or_else(|| {
1076 tracing::error!("bad created_by uuid in saved_glw_data");
1077 Error::Database
1078 })
1079 })
1080 .transpose()?;
1081 let source_kind = GlwDataSourceKind::from_db_str(&source_kind_str).ok_or_else(|| {
1082 tracing::error!("unrecognised source_kind `{source_kind_str}` in saved_glw_data");
1083 Error::Database
1084 })?;
1085 Ok(GlwDataRow {
1086 glw_data_id,
1087 owner_user_id,
1088 owner_group_id,
1089 created_by,
1090 name,
1091 source_kind,
1092 source_event_id: source_event_id.and_then(|v| u32::try_from(v).ok()),
1093 source_event_key,
1094 payload_json,
1095 event_id: event_id.and_then(|v| u32::try_from(v).ok()),
1096 event_key,
1097 event_name,
1098 fetched_at,
1099 created_at,
1100 })
1101}
1102
1103pub async fn assert_can_read_glw_data(
1112 db: &SqlitePool,
1113 current_user: Uuid,
1114 glw_data_id: Uuid,
1115) -> Result<GlwDataRow, Error> {
1116 let row = fetch_glw_data_row(db, glw_data_id).await?;
1117 let destination =
1118 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1119 let visible = match destination {
1120 Destination::Personal => {
1121 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
1122 }
1123 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
1124 .await?
1125 .is_some(),
1126 };
1127 if visible {
1128 Ok(row)
1129 } else {
1130 Err(Error::NotFound(format!("glw data {glw_data_id}")))
1131 }
1132}
1133
1134pub async fn assert_can_delete_glw_data(
1147 db: &SqlitePool,
1148 current_user: Uuid,
1149 glw_data_id: Uuid,
1150) -> Result<GlwDataRow, Error> {
1151 let row = fetch_glw_data_row(db, glw_data_id).await?;
1152 let destination =
1153 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1154 match destination {
1155 Destination::Personal => {
1156 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
1157 if owner == Some(current_user) {
1158 Ok(row)
1159 } else {
1160 Err(Error::Forbidden(format!(
1161 "not allowed to delete glw data {glw_data_id}"
1162 )))
1163 }
1164 }
1165 Destination::Group { group_id } => {
1166 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
1167 Ok(row)
1168 } else {
1169 Err(Error::Forbidden(
1170 "must be a group owner to delete group glw data".to_owned(),
1171 ))
1172 }
1173 }
1174 }
1175}
1176
1177#[derive(Debug, Clone)]
1188pub struct ThemeRow {
1189 pub theme_id: Uuid,
1191 pub owner_user_id: Option<Vec<u8>>,
1193 pub owner_group_id: Option<Vec<u8>>,
1195 pub created_by: Option<Uuid>,
1198 pub name: String,
1200 pub settings_json: String,
1202 pub created_at: DateTime<Utc>,
1204 pub updated_at: DateTime<Utc>,
1206}
1207
1208type ThemeRowTuple = (
1210 Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, DateTime<Utc>, DateTime<Utc>, );
1218
1219async fn fetch_theme_row(db: &SqlitePool, theme_id: Uuid) -> Result<ThemeRow, Error> {
1221 let row: Option<ThemeRowTuple> = sqlx::query_as(
1222 "SELECT owner_user_id, owner_group_id, created_by, name, settings_json, \
1223 created_at, updated_at \
1224 FROM themes WHERE theme_id = ?1",
1225 )
1226 .bind(theme_id.as_bytes().to_vec())
1227 .fetch_optional(db)
1228 .await
1229 .map_err(|err| {
1230 tracing::error!("theme fetch failed: {err}");
1231 Error::Database
1232 })?;
1233 let (
1234 owner_user_id,
1235 owner_group_id,
1236 created_by_bytes,
1237 name,
1238 settings_json,
1239 created_at,
1240 updated_at,
1241 ) = row.ok_or_else(|| Error::NotFound(format!("theme {theme_id}")))?;
1242 let created_by = created_by_bytes
1243 .as_deref()
1244 .map(uuid_from_bytes)
1245 .map(|opt| {
1246 opt.ok_or_else(|| {
1247 tracing::error!("bad created_by uuid in themes");
1248 Error::Database
1249 })
1250 })
1251 .transpose()?;
1252 Ok(ThemeRow {
1253 theme_id,
1254 owner_user_id,
1255 owner_group_id,
1256 created_by,
1257 name,
1258 settings_json,
1259 created_at,
1260 updated_at,
1261 })
1262}
1263
1264pub async fn assert_can_read_theme(
1272 db: &SqlitePool,
1273 current_user: Uuid,
1274 theme_id: Uuid,
1275) -> Result<ThemeRow, Error> {
1276 let row = fetch_theme_row(db, theme_id).await?;
1277 let destination =
1278 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1279 let visible = match destination {
1280 Destination::Personal => {
1281 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
1282 }
1283 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
1284 .await?
1285 .is_some(),
1286 };
1287 if visible {
1288 Ok(row)
1289 } else {
1290 Err(Error::NotFound(format!("theme {theme_id}")))
1291 }
1292}
1293
1294pub async fn assert_can_modify_theme(
1303 db: &SqlitePool,
1304 current_user: Uuid,
1305 theme_id: Uuid,
1306) -> Result<ThemeRow, Error> {
1307 let row = fetch_theme_row(db, theme_id).await?;
1308 let destination =
1309 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1310 match destination {
1311 Destination::Personal => {
1312 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
1313 if owner == Some(current_user) {
1314 Ok(row)
1315 } else {
1316 Err(Error::Forbidden(format!(
1317 "not allowed to modify theme {theme_id}"
1318 )))
1319 }
1320 }
1321 Destination::Group { group_id } => {
1322 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
1323 Ok(row)
1324 } else {
1325 Err(Error::Forbidden(
1326 "must be a group owner to modify a group theme".to_owned(),
1327 ))
1328 }
1329 }
1330 }
1331}
1332
1333#[derive(Debug, Clone)]
1345pub struct LogoRow {
1346 pub logo_id: Uuid,
1348 pub owner_user_id: Option<Vec<u8>>,
1350 pub owner_group_id: Option<Vec<u8>>,
1352 pub uploaded_by: Option<Uuid>,
1355 pub name: String,
1357 pub content_type: String,
1359 pub image_filename: String,
1361 pub width: u32,
1363 pub height: u32,
1365 pub byte_size: u64,
1367 pub created_at: DateTime<Utc>,
1369}
1370
1371#[derive(Debug, Clone, Serialize)]
1374pub struct LogoView {
1375 pub logo_id: Uuid,
1377 pub destination: Destination,
1379 pub uploaded_by: Option<Uuid>,
1382 pub uploaded_by_username: Option<String>,
1384 pub uploaded_by_legacy_name: Option<String>,
1386 pub name: String,
1388 pub content_type: String,
1390 pub width: u32,
1392 pub height: u32,
1394 pub byte_size: u64,
1396 pub created_at: DateTime<Utc>,
1398}
1399
1400type LogoRowTuple = (
1402 Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, String, i64, i64, i64, DateTime<Utc>, );
1413
1414async fn fetch_logo_row(db: &SqlitePool, logo_id: Uuid) -> Result<LogoRow, Error> {
1416 let row: Option<LogoRowTuple> = sqlx::query_as(
1417 "SELECT owner_user_id, owner_group_id, uploaded_by, name, content_type, \
1418 image_filename, width, height, byte_size, created_at \
1419 FROM saved_logos WHERE logo_id = ?1",
1420 )
1421 .bind(logo_id.as_bytes().to_vec())
1422 .fetch_optional(db)
1423 .await
1424 .map_err(|err| {
1425 tracing::error!("logo fetch failed: {err}");
1426 Error::Database
1427 })?;
1428 let (
1429 owner_user_id,
1430 owner_group_id,
1431 uploaded_by_bytes,
1432 name,
1433 content_type,
1434 image_filename,
1435 width,
1436 height,
1437 byte_size,
1438 created_at,
1439 ) = row.ok_or_else(|| Error::NotFound(format!("logo {logo_id}")))?;
1440 let uploaded_by = uploaded_by_bytes
1441 .as_deref()
1442 .map(uuid_from_bytes)
1443 .map(|opt| {
1444 opt.ok_or_else(|| {
1445 tracing::error!("bad uploaded_by uuid in saved_logos");
1446 Error::Database
1447 })
1448 })
1449 .transpose()?;
1450 Ok(LogoRow {
1451 logo_id,
1452 owner_user_id,
1453 owner_group_id,
1454 uploaded_by,
1455 name,
1456 content_type,
1457 image_filename,
1458 width: u32::try_from(width).unwrap_or(0),
1459 height: u32::try_from(height).unwrap_or(0),
1460 byte_size: u64::try_from(byte_size).unwrap_or(0),
1461 created_at,
1462 })
1463}
1464
1465pub async fn assert_can_read_logo(
1473 db: &SqlitePool,
1474 current_user: Uuid,
1475 logo_id: Uuid,
1476) -> Result<LogoRow, Error> {
1477 let row = fetch_logo_row(db, logo_id).await?;
1478 let destination =
1479 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1480 let visible = match destination {
1481 Destination::Personal => {
1482 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
1483 }
1484 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
1485 .await?
1486 .is_some(),
1487 };
1488 if visible {
1489 Ok(row)
1490 } else {
1491 Err(Error::NotFound(format!("logo {logo_id}")))
1492 }
1493}
1494
1495pub async fn assert_can_delete_logo(
1507 db: &SqlitePool,
1508 current_user: Uuid,
1509 logo_id: Uuid,
1510) -> Result<LogoRow, Error> {
1511 let row = fetch_logo_row(db, logo_id).await?;
1512 let destination =
1513 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1514 match destination {
1515 Destination::Personal => {
1516 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
1517 if owner == Some(current_user) {
1518 Ok(row)
1519 } else {
1520 Err(Error::Forbidden(format!(
1521 "not allowed to delete logo {logo_id}"
1522 )))
1523 }
1524 }
1525 Destination::Group { group_id } => {
1526 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
1527 Ok(row)
1528 } else {
1529 Err(Error::Forbidden(
1530 "must be a group owner to delete a group logo".to_owned(),
1531 ))
1532 }
1533 }
1534 }
1535}