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#[derive(Debug, Clone, Serialize)]
147pub struct NotecardView {
148 pub notecard_id: Uuid,
150 pub destination: Destination,
152 pub uploaded_by: Option<Uuid>,
157 pub uploaded_by_username: Option<String>,
159 pub uploaded_by_legacy_name: Option<String>,
161 pub name: String,
163 pub created_at: DateTime<Utc>,
165 pub start_region: Option<String>,
168 pub end_region: Option<String>,
171 pub waypoint_count: Option<u32>,
173 pub lower_left_x: Option<u16>,
176 pub lower_left_y: Option<u16>,
178 pub upper_right_x: Option<u16>,
180 pub upper_right_y: Option<u16>,
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct RenderView {
187 pub render_id: Uuid,
189 pub destination: Destination,
191 pub created_by: Option<Uuid>,
194 pub created_by_username: Option<String>,
196 pub created_by_legacy_name: Option<String>,
198 pub notecard_id: Option<Uuid>,
200 pub notecard_name: Option<String>,
206 pub kind: String,
208 pub status: String,
210 pub error_message: Option<String>,
212 pub created_at: DateTime<Utc>,
214 pub finished_at: Option<DateTime<Utc>>,
216 pub has_without_route: bool,
218 pub content_type: Option<String>,
220 pub lower_left_x: Option<u16>,
224 pub lower_left_y: Option<u16>,
226 pub upper_right_x: Option<u16>,
228 pub upper_right_y: Option<u16>,
230 pub glw_data_id: Option<Uuid>,
236 pub glw_data_name: Option<String>,
239}
240
241pub async fn assert_can_write(
250 db: &SqlitePool,
251 current_user: Uuid,
252 destination: Destination,
253) -> Result<(), Error> {
254 match destination {
255 Destination::Personal => Ok(()),
256 Destination::Group { group_id } => {
257 groups::require_exists(db, group_id).await?;
258 let role = groups::lookup_role(db, group_id, current_user).await?;
259 if role == Some(GroupRole::Owner) {
260 Ok(())
261 } else {
262 Err(Error::Forbidden(format!(
263 "must be an owner of group {group_id} to save items there"
264 )))
265 }
266 }
267 }
268}
269
270pub async fn assert_can_view(
279 db: &SqlitePool,
280 current_user: Uuid,
281 destination: Destination,
282) -> Result<Option<GroupRole>, Error> {
283 match destination {
284 Destination::Personal => Ok(None),
285 Destination::Group { group_id } => {
286 groups::require_exists(db, group_id).await?;
287 let role = groups::lookup_role(db, group_id, current_user).await?;
288 role.map_or_else(
289 || {
290 Err(Error::Forbidden(format!(
291 "not a member of group {group_id}"
292 )))
293 },
294 |r| Ok(Some(r)),
295 )
296 }
297 }
298}
299
300pub fn destination_from_columns(
308 owner_user_id: Option<Vec<u8>>,
309 owner_group_id: Option<Vec<u8>>,
310) -> Result<Destination, Error> {
311 match (owner_user_id, owner_group_id) {
312 (Some(_), None) => Ok(Destination::Personal),
313 (None, Some(gid_bytes)) => {
314 let group_id = uuid_from_bytes(&gid_bytes).ok_or_else(|| {
315 tracing::error!("bad group uuid blob in destination column");
316 Error::Database
317 })?;
318 Ok(Destination::Group { group_id })
319 }
320 _ => {
321 tracing::error!("saved row had both or neither owner column set");
322 Err(Error::Database)
323 }
324 }
325}
326
327pub async fn assert_can_read_notecard(
336 db: &SqlitePool,
337 current_user: Uuid,
338 notecard_id: Uuid,
339) -> Result<NotecardRow, Error> {
340 let row = fetch_notecard_row(db, notecard_id).await?;
341 let destination =
342 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
343 let visible = match destination {
344 Destination::Personal => {
345 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
346 }
347 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
348 .await?
349 .is_some(),
350 };
351 if visible {
352 Ok(row)
353 } else {
354 Err(Error::NotFound(format!("notecard {notecard_id}")))
355 }
356}
357
358pub async fn assert_can_read_render(
368 db: &SqlitePool,
369 current_user: Uuid,
370 render_id: Uuid,
371) -> Result<RenderRow, Error> {
372 let row = fetch_render_row(db, render_id).await?;
373 let destination =
374 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
375 let visible = match destination {
376 Destination::Personal => {
377 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
378 }
379 Destination::Group { group_id } => {
380 match groups::lookup_role(db, group_id, current_user).await? {
381 Some(GroupRole::Owner) => true,
382 Some(GroupRole::Member) => row.status == "done",
383 None => false,
384 }
385 }
386 };
387 if visible {
388 Ok(row)
389 } else {
390 Err(Error::NotFound(format!("render {render_id}")))
391 }
392}
393
394pub async fn assert_can_delete_render(
402 db: &SqlitePool,
403 current_user: Uuid,
404 render_id: Uuid,
405) -> Result<RenderRow, Error> {
406 let row = fetch_render_row(db, render_id).await?;
407 let destination =
408 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
409 match destination {
410 Destination::Personal => {
411 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
412 if owner == Some(current_user) {
413 Ok(row)
414 } else {
415 Err(Error::Forbidden(format!(
416 "not allowed to delete render {render_id}"
417 )))
418 }
419 }
420 Destination::Group { group_id } => {
421 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
422 Ok(row)
423 } else {
424 Err(Error::Forbidden(
425 "must be a group owner to delete a group render".to_owned(),
426 ))
427 }
428 }
429 }
430}
431
432pub async fn assert_can_delete_notecard(
439 db: &SqlitePool,
440 current_user: Uuid,
441 notecard_id: Uuid,
442) -> Result<NotecardRow, Error> {
443 let row = fetch_notecard_row(db, notecard_id).await?;
444 let destination =
445 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
446 match destination {
447 Destination::Personal => {
448 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
449 if owner == Some(current_user) {
450 Ok(row)
451 } else {
452 Err(Error::Forbidden(format!(
453 "not allowed to delete notecard {notecard_id}"
454 )))
455 }
456 }
457 Destination::Group { group_id } => {
458 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
459 Ok(row)
460 } else {
461 Err(Error::Forbidden(
462 "must be a group owner to delete a group notecard".to_owned(),
463 ))
464 }
465 }
466 }
467}
468
469#[derive(Debug, Clone)]
471pub struct NotecardRow {
472 pub notecard_id: Uuid,
474 pub owner_user_id: Option<Vec<u8>>,
476 pub owner_group_id: Option<Vec<u8>>,
478 pub uploaded_by: Option<Uuid>,
481 pub name: String,
483 pub body: String,
485 pub created_at: DateTime<Utc>,
487 pub lower_left_x: Option<u16>,
490 pub lower_left_y: Option<u16>,
492 pub upper_right_x: Option<u16>,
494 pub upper_right_y: Option<u16>,
496}
497
498#[derive(Debug, Clone)]
500pub struct RenderRow {
501 pub render_id: Uuid,
503 pub owner_user_id: Option<Vec<u8>>,
505 pub owner_group_id: Option<Vec<u8>>,
507 pub created_by: Option<Uuid>,
510 pub notecard_id: Option<Uuid>,
512 pub kind: String,
514 pub status: String,
516 pub error_message: Option<String>,
518 pub settings_json: String,
520 pub metadata_json: Option<String>,
522 pub content_type: Option<String>,
524 pub image_filename: Option<String>,
526 pub image_without_route_filename: Option<String>,
528 pub created_at: DateTime<Utc>,
530 pub finished_at: Option<DateTime<Utc>>,
532 pub lower_left_x: Option<u16>,
534 pub lower_left_y: Option<u16>,
536 pub upper_right_x: Option<u16>,
538 pub upper_right_y: Option<u16>,
540 pub glw_data_id: Option<Uuid>,
542}
543
544type NotecardRowTuple = (
546 Option<Vec<u8>>,
547 Option<Vec<u8>>,
548 Option<Vec<u8>>,
549 String,
550 String,
551 DateTime<Utc>,
552 Option<i64>,
553 Option<i64>,
554 Option<i64>,
555 Option<i64>,
556);
557
558async fn fetch_notecard_row(db: &SqlitePool, notecard_id: Uuid) -> Result<NotecardRow, Error> {
560 let row: Option<NotecardRowTuple> = sqlx::query_as(
561 "SELECT owner_user_id, owner_group_id, uploaded_by, name, body, created_at, \
562 lower_left_x, lower_left_y, upper_right_x, upper_right_y \
563 FROM saved_notecards WHERE notecard_id = ?1",
564 )
565 .bind(notecard_id.as_bytes().to_vec())
566 .fetch_optional(db)
567 .await
568 .map_err(|err| {
569 tracing::error!("notecard fetch failed: {err}");
570 Error::Database
571 })?;
572 let (
573 owner_user_id,
574 owner_group_id,
575 uploaded_by_bytes,
576 name,
577 body,
578 created_at,
579 lower_left_x,
580 lower_left_y,
581 upper_right_x,
582 upper_right_y,
583 ) = row.ok_or_else(|| Error::NotFound(format!("notecard {notecard_id}")))?;
584 let uploaded_by = uploaded_by_bytes
585 .as_deref()
586 .map(uuid_from_bytes)
587 .map(|opt| {
588 opt.ok_or_else(|| {
589 tracing::error!("bad uploaded_by uuid in saved_notecards");
590 Error::Database
591 })
592 })
593 .transpose()?;
594 Ok(NotecardRow {
595 notecard_id,
596 owner_user_id,
597 owner_group_id,
598 uploaded_by,
599 name,
600 body,
601 created_at,
602 lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
603 lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
604 upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
605 upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
606 })
607}
608
609#[derive(sqlx::FromRow)]
613struct RenderRowDb {
614 owner_user_id: Option<Vec<u8>>,
616 owner_group_id: Option<Vec<u8>>,
618 created_by: Option<Vec<u8>>,
621 notecard_id: Option<Vec<u8>>,
623 kind: String,
625 status: String,
627 error_message: Option<String>,
629 settings_json: String,
631 metadata_json: Option<String>,
633 content_type: Option<String>,
635 image_filename: Option<String>,
637 image_without_route_filename: Option<String>,
639 created_at: DateTime<Utc>,
641 finished_at: Option<DateTime<Utc>>,
643 lower_left_x: Option<i64>,
645 lower_left_y: Option<i64>,
647 upper_right_x: Option<i64>,
649 upper_right_y: Option<i64>,
651 glw_data_id: Option<Vec<u8>>,
653}
654
655async fn fetch_render_row(db: &SqlitePool, render_id: Uuid) -> Result<RenderRow, Error> {
657 let row: Option<RenderRowDb> = sqlx::query_as(
658 "SELECT owner_user_id, owner_group_id, created_by, notecard_id, kind, status, \
659 error_message, settings_json, metadata_json, content_type, \
660 image_filename, image_without_route_filename, created_at, finished_at, \
661 lower_left_x, lower_left_y, upper_right_x, upper_right_y, glw_data_id \
662 FROM saved_renders WHERE render_id = ?1",
663 )
664 .bind(render_id.as_bytes().to_vec())
665 .fetch_optional(db)
666 .await
667 .map_err(|err| {
668 tracing::error!("render fetch failed: {err}");
669 Error::Database
670 })?;
671 let RenderRowDb {
672 owner_user_id,
673 owner_group_id,
674 created_by: created_by_bytes,
675 notecard_id: notecard_bytes,
676 kind,
677 status,
678 error_message,
679 settings_json,
680 metadata_json,
681 content_type,
682 image_filename,
683 image_without_route_filename,
684 created_at,
685 finished_at,
686 lower_left_x,
687 lower_left_y,
688 upper_right_x,
689 upper_right_y,
690 glw_data_id: glw_data_id_bytes,
691 } = row.ok_or_else(|| Error::NotFound(format!("render {render_id}")))?;
692 let glw_data_id = glw_data_id_bytes
693 .as_deref()
694 .map(uuid_from_bytes)
695 .map(|opt| {
696 opt.ok_or_else(|| {
697 tracing::error!("bad glw_data_id uuid in saved_renders");
698 Error::Database
699 })
700 })
701 .transpose()?;
702 let created_by = created_by_bytes
703 .as_deref()
704 .map(uuid_from_bytes)
705 .map(|opt| {
706 opt.ok_or_else(|| {
707 tracing::error!("bad created_by uuid in saved_renders");
708 Error::Database
709 })
710 })
711 .transpose()?;
712 let notecard_id = notecard_bytes
713 .as_deref()
714 .map(uuid_from_bytes)
715 .map(|opt| {
716 opt.ok_or_else(|| {
717 tracing::error!("bad notecard_id uuid in saved_renders");
718 Error::Database
719 })
720 })
721 .transpose()?;
722 Ok(RenderRow {
723 render_id,
724 owner_user_id,
725 owner_group_id,
726 created_by,
727 notecard_id,
728 kind,
729 status,
730 error_message,
731 settings_json,
732 metadata_json,
733 content_type,
734 image_filename,
735 image_without_route_filename,
736 created_at,
737 finished_at,
738 lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
739 lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
740 upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
741 upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
742 glw_data_id,
743 })
744}
745
746pub async fn recover_orphaned_in_progress(pool: &SqlitePool) -> Result<u64, Error> {
765 let now = Utc::now();
766 let result = sqlx::query(
767 "UPDATE saved_renders \
768 SET status = 'failed', finished_at = ?1, \
769 error_message = 'server restarted before render completed' \
770 WHERE status = 'in_progress'",
771 )
772 .bind(now)
773 .execute(pool)
774 .await
775 .map_err(|err| {
776 tracing::error!("recover orphaned in_progress renders failed: {err}");
777 Error::Database
778 })?;
779 Ok(result.rows_affected())
780}
781
782pub async fn run_orphan_sweeper(
788 db: SqlitePool,
789 storage_dir: Arc<Path>,
790 dirty: Arc<AtomicBool>,
791 period: Duration,
792) {
793 let mut tick = interval(period);
794 loop {
795 tick.tick().await;
796 if !dirty.swap(false, Ordering::AcqRel) {
797 tracing::debug!("orphan sweeper: no work flagged, skipping");
798 continue;
799 }
800 match sweep_once(&db, storage_dir.as_ref()).await {
801 Ok(count) => {
802 if count > 0 {
803 tracing::info!("orphan sweeper: removed {count} stale render file(s)");
804 }
805 }
806 Err(err) => {
807 tracing::warn!("orphan sweeper run failed: {err}; will retry on next tick");
808 dirty.store(true, Ordering::Release);
809 }
810 }
811 }
812}
813
814async fn sweep_once(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
818 let renders = sweep_renders(db, storage_dir).await?;
819 let logos = sweep_logos(db, storage_dir).await?;
820 Ok(renders.saturating_add(logos))
821}
822
823async fn sweep_renders(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
825 let files = storage::list_render_files(storage_dir)?;
826 let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT render_id FROM saved_renders")
827 .fetch_all(db)
828 .await
829 .map_err(|err| {
830 tracing::error!("sweeper render id query failed: {err}");
831 Error::Database
832 })?;
833 let live_set: HashSet<Uuid> = live
834 .into_iter()
835 .filter_map(|b| uuid_from_bytes(&b))
836 .collect();
837 let mut removed = 0_usize;
838 for filename in files {
839 let Some(id) = storage::parse_render_id_from_filename(&filename) else {
840 continue;
841 };
842 if live_set.contains(&id) {
843 continue;
844 }
845 if let Err(err) = storage::try_delete_render_file(storage_dir, &filename) {
846 tracing::warn!("sweeper failed to unlink {filename}: {err}");
847 continue;
848 }
849 removed = removed.saturating_add(1);
850 }
851 Ok(removed)
852}
853
854async fn sweep_logos(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
856 let files = storage::list_logo_files(storage_dir)?;
857 let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT logo_id FROM saved_logos")
858 .fetch_all(db)
859 .await
860 .map_err(|err| {
861 tracing::error!("sweeper logo id query failed: {err}");
862 Error::Database
863 })?;
864 let live_set: HashSet<Uuid> = live
865 .into_iter()
866 .filter_map(|b| uuid_from_bytes(&b))
867 .collect();
868 let mut removed = 0_usize;
869 for filename in files {
870 let Some(id) = storage::parse_logo_id_from_filename(&filename) else {
871 continue;
872 };
873 if live_set.contains(&id) {
874 continue;
875 }
876 if let Err(err) = storage::try_delete_logo_file(storage_dir, &filename) {
877 tracing::warn!("sweeper failed to unlink {filename}: {err}");
878 continue;
879 }
880 removed = removed.saturating_add(1);
881 }
882 Ok(removed)
883}
884
885#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
899#[serde(rename_all = "snake_case")]
900pub enum GlwDataSourceKind {
901 EventId,
903 EventKey,
905 PastedJson,
907}
908
909impl GlwDataSourceKind {
910 #[must_use]
912 pub const fn as_db_str(self) -> &'static str {
913 match self {
914 Self::EventId => "event_id",
915 Self::EventKey => "event_key",
916 Self::PastedJson => "pasted_json",
917 }
918 }
919
920 #[must_use]
923 pub fn from_db_str(s: &str) -> Option<Self> {
924 match s {
925 "event_id" => Some(Self::EventId),
926 "event_key" => Some(Self::EventKey),
927 "pasted_json" => Some(Self::PastedJson),
928 _ => None,
929 }
930 }
931}
932
933#[derive(Debug, Clone)]
935pub struct GlwDataRow {
936 pub glw_data_id: Uuid,
938 pub owner_user_id: Option<Vec<u8>>,
940 pub owner_group_id: Option<Vec<u8>>,
942 pub created_by: Option<Uuid>,
944 pub name: String,
946 pub source_kind: GlwDataSourceKind,
948 pub source_event_id: Option<u32>,
950 pub source_event_key: Option<String>,
952 pub payload_json: String,
955 pub event_id: Option<u32>,
957 pub event_key: Option<String>,
959 pub event_name: Option<String>,
961 pub fetched_at: DateTime<Utc>,
963 pub created_at: DateTime<Utc>,
965}
966
967#[derive(Debug, Clone, Serialize)]
971pub struct GlwDataView {
972 pub glw_data_id: Uuid,
974 pub destination: Destination,
976 pub created_by: Option<Uuid>,
979 pub created_by_username: Option<String>,
981 pub created_by_legacy_name: Option<String>,
983 pub name: String,
985 pub source_kind: GlwDataSourceKind,
987 pub source_event_id: Option<u32>,
989 pub source_event_key: Option<String>,
991 pub event_id: Option<u32>,
993 pub event_key: Option<String>,
995 pub event_name: Option<String>,
997 pub fetched_at: DateTime<Utc>,
999 pub created_at: DateTime<Utc>,
1001}
1002
1003type GlwDataRowTuple = (
1006 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>, );
1020
1021async fn fetch_glw_data_row(db: &SqlitePool, glw_data_id: Uuid) -> Result<GlwDataRow, Error> {
1023 let row: Option<GlwDataRowTuple> = sqlx::query_as(
1024 "SELECT owner_user_id, owner_group_id, created_by, name, source_kind, \
1025 source_event_id, source_event_key, payload_json, \
1026 event_id, event_key, event_name, fetched_at, created_at \
1027 FROM saved_glw_data WHERE glw_data_id = ?1",
1028 )
1029 .bind(glw_data_id.as_bytes().to_vec())
1030 .fetch_optional(db)
1031 .await
1032 .map_err(|err| {
1033 tracing::error!("GLW data fetch failed: {err}");
1034 Error::Database
1035 })?;
1036 let (
1037 owner_user_id,
1038 owner_group_id,
1039 created_by_bytes,
1040 name,
1041 source_kind_str,
1042 source_event_id,
1043 source_event_key,
1044 payload_json,
1045 event_id,
1046 event_key,
1047 event_name,
1048 fetched_at,
1049 created_at,
1050 ) = row.ok_or_else(|| Error::NotFound(format!("glw data {glw_data_id}")))?;
1051 let created_by = created_by_bytes
1052 .as_deref()
1053 .map(uuid_from_bytes)
1054 .map(|opt| {
1055 opt.ok_or_else(|| {
1056 tracing::error!("bad created_by uuid in saved_glw_data");
1057 Error::Database
1058 })
1059 })
1060 .transpose()?;
1061 let source_kind = GlwDataSourceKind::from_db_str(&source_kind_str).ok_or_else(|| {
1062 tracing::error!("unrecognised source_kind `{source_kind_str}` in saved_glw_data");
1063 Error::Database
1064 })?;
1065 Ok(GlwDataRow {
1066 glw_data_id,
1067 owner_user_id,
1068 owner_group_id,
1069 created_by,
1070 name,
1071 source_kind,
1072 source_event_id: source_event_id.and_then(|v| u32::try_from(v).ok()),
1073 source_event_key,
1074 payload_json,
1075 event_id: event_id.and_then(|v| u32::try_from(v).ok()),
1076 event_key,
1077 event_name,
1078 fetched_at,
1079 created_at,
1080 })
1081}
1082
1083pub async fn assert_can_read_glw_data(
1092 db: &SqlitePool,
1093 current_user: Uuid,
1094 glw_data_id: Uuid,
1095) -> Result<GlwDataRow, Error> {
1096 let row = fetch_glw_data_row(db, glw_data_id).await?;
1097 let destination =
1098 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1099 let visible = match destination {
1100 Destination::Personal => {
1101 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
1102 }
1103 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
1104 .await?
1105 .is_some(),
1106 };
1107 if visible {
1108 Ok(row)
1109 } else {
1110 Err(Error::NotFound(format!("glw data {glw_data_id}")))
1111 }
1112}
1113
1114pub async fn assert_can_delete_glw_data(
1127 db: &SqlitePool,
1128 current_user: Uuid,
1129 glw_data_id: Uuid,
1130) -> Result<GlwDataRow, Error> {
1131 let row = fetch_glw_data_row(db, glw_data_id).await?;
1132 let destination =
1133 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1134 match destination {
1135 Destination::Personal => {
1136 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
1137 if owner == Some(current_user) {
1138 Ok(row)
1139 } else {
1140 Err(Error::Forbidden(format!(
1141 "not allowed to delete glw data {glw_data_id}"
1142 )))
1143 }
1144 }
1145 Destination::Group { group_id } => {
1146 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
1147 Ok(row)
1148 } else {
1149 Err(Error::Forbidden(
1150 "must be a group owner to delete group glw data".to_owned(),
1151 ))
1152 }
1153 }
1154 }
1155}
1156
1157#[derive(Debug, Clone)]
1169pub struct LogoRow {
1170 pub logo_id: Uuid,
1172 pub owner_user_id: Option<Vec<u8>>,
1174 pub owner_group_id: Option<Vec<u8>>,
1176 pub uploaded_by: Option<Uuid>,
1179 pub name: String,
1181 pub content_type: String,
1183 pub image_filename: String,
1185 pub width: u32,
1187 pub height: u32,
1189 pub byte_size: u64,
1191 pub created_at: DateTime<Utc>,
1193}
1194
1195#[derive(Debug, Clone, Serialize)]
1198pub struct LogoView {
1199 pub logo_id: Uuid,
1201 pub destination: Destination,
1203 pub uploaded_by: Option<Uuid>,
1206 pub uploaded_by_username: Option<String>,
1208 pub uploaded_by_legacy_name: Option<String>,
1210 pub name: String,
1212 pub content_type: String,
1214 pub width: u32,
1216 pub height: u32,
1218 pub byte_size: u64,
1220 pub created_at: DateTime<Utc>,
1222}
1223
1224type LogoRowTuple = (
1226 Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, String, i64, i64, i64, DateTime<Utc>, );
1237
1238async fn fetch_logo_row(db: &SqlitePool, logo_id: Uuid) -> Result<LogoRow, Error> {
1240 let row: Option<LogoRowTuple> = sqlx::query_as(
1241 "SELECT owner_user_id, owner_group_id, uploaded_by, name, content_type, \
1242 image_filename, width, height, byte_size, created_at \
1243 FROM saved_logos WHERE logo_id = ?1",
1244 )
1245 .bind(logo_id.as_bytes().to_vec())
1246 .fetch_optional(db)
1247 .await
1248 .map_err(|err| {
1249 tracing::error!("logo fetch failed: {err}");
1250 Error::Database
1251 })?;
1252 let (
1253 owner_user_id,
1254 owner_group_id,
1255 uploaded_by_bytes,
1256 name,
1257 content_type,
1258 image_filename,
1259 width,
1260 height,
1261 byte_size,
1262 created_at,
1263 ) = row.ok_or_else(|| Error::NotFound(format!("logo {logo_id}")))?;
1264 let uploaded_by = uploaded_by_bytes
1265 .as_deref()
1266 .map(uuid_from_bytes)
1267 .map(|opt| {
1268 opt.ok_or_else(|| {
1269 tracing::error!("bad uploaded_by uuid in saved_logos");
1270 Error::Database
1271 })
1272 })
1273 .transpose()?;
1274 Ok(LogoRow {
1275 logo_id,
1276 owner_user_id,
1277 owner_group_id,
1278 uploaded_by,
1279 name,
1280 content_type,
1281 image_filename,
1282 width: u32::try_from(width).unwrap_or(0),
1283 height: u32::try_from(height).unwrap_or(0),
1284 byte_size: u64::try_from(byte_size).unwrap_or(0),
1285 created_at,
1286 })
1287}
1288
1289pub async fn assert_can_read_logo(
1297 db: &SqlitePool,
1298 current_user: Uuid,
1299 logo_id: Uuid,
1300) -> Result<LogoRow, Error> {
1301 let row = fetch_logo_row(db, logo_id).await?;
1302 let destination =
1303 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1304 let visible = match destination {
1305 Destination::Personal => {
1306 row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
1307 }
1308 Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
1309 .await?
1310 .is_some(),
1311 };
1312 if visible {
1313 Ok(row)
1314 } else {
1315 Err(Error::NotFound(format!("logo {logo_id}")))
1316 }
1317}
1318
1319pub async fn assert_can_delete_logo(
1331 db: &SqlitePool,
1332 current_user: Uuid,
1333 logo_id: Uuid,
1334) -> Result<LogoRow, Error> {
1335 let row = fetch_logo_row(db, logo_id).await?;
1336 let destination =
1337 destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
1338 match destination {
1339 Destination::Personal => {
1340 let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
1341 if owner == Some(current_user) {
1342 Ok(row)
1343 } else {
1344 Err(Error::Forbidden(format!(
1345 "not allowed to delete logo {logo_id}"
1346 )))
1347 }
1348 }
1349 Destination::Group { group_id } => {
1350 if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
1351 Ok(row)
1352 } else {
1353 Err(Error::Forbidden(
1354 "must be a group owner to delete a group logo".to_owned(),
1355 ))
1356 }
1357 }
1358 }
1359}