Skip to main content

sl_map_web/
library.rs

1//! Saved notecards and saved renders: scope/destination types, permission
2//! helpers, and the orphan-file sweeper.
3//!
4//! Every handler that mutates or reads a saved item funnels through one of
5//! the `assert_can_*` helpers here so the permission rules live in exactly
6//! one place.
7
8use 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/// Owner scope of a saved notecard or saved render. Exactly one of the two
26/// variants is set; the schema CHECK constraint mirrors this at the DB.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(tag = "kind", rename_all = "lowercase")]
29pub enum Destination {
30    /// Owned by a single user (the user's personal library).
31    Personal,
32    /// Owned by a group.
33    Group {
34        /// the owning group's id.
35        group_id: Uuid,
36    },
37}
38
39impl Destination {
40    /// Parse a destination from a form / query string. Accepted values:
41    /// `"personal"` or `"group:<uuid>"`.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::BadRequest`] for any other shape.
46    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    /// Round-trip a destination back to the string form. Useful for hidden
66    /// form fields and links.
67    #[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
76/// Maximum length, in unicode codepoints, of a user-supplied display
77/// name. Applies to `groups.name` and `saved_notecards.name`.
78pub const MAX_DISPLAY_NAME_LEN: usize = 128;
79
80/// True if `c` belongs to the unicode `Cf` (Format) general category.
81/// Hand-coded from Unicode 15 so we do not have to pull in a properties
82/// crate. The set includes the bidi controls (LRE/RLE/PDF/LRO/RLO and
83/// LRI/RLI/FSI/PDI), zero-width joiners/marks, the BOM, and the
84/// language-tag block — every codepoint whose only purpose is to change
85/// how surrounding text is rendered or processed.
86const 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
113/// Trim a user-supplied display name and reject it if it is empty,
114/// longer than [`MAX_DISPLAY_NAME_LEN`] codepoints, contains any unicode
115/// control character (`char::is_control` — NUL, TAB, LF, CR, the C1
116/// control block, DEL), or contains any unicode `Cf` Format character
117/// (bidi overrides, zero-width joiners, the BOM, language-tag block,
118/// etc. — see `is_unicode_format`). `field` is interpolated into the
119/// error message so the caller does not need to repeat the label.
120///
121/// # Errors
122///
123/// Returns [`Error::BadRequest`] for any of the rejection cases above.
124pub 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/// Public, serializable record of a saved notecard.
146#[derive(Debug, Clone, Serialize)]
147pub struct NotecardView {
148    /// the notecard's identifier.
149    pub notecard_id: Uuid,
150    /// the destination the notecard belongs to.
151    pub destination: Destination,
152    /// the avatar that uploaded the notecard, or `None` if that
153    /// account has since been deleted (the FK is `ON DELETE SET NULL`,
154    /// so audit history survives the account removal but the link is
155    /// severed).
156    pub uploaded_by: Option<Uuid>,
157    /// the uploader's username, if the account still exists.
158    pub uploaded_by_username: Option<String>,
159    /// the uploader's legacy name, if the account still exists.
160    pub uploaded_by_legacy_name: Option<String>,
161    /// the human-supplied display name of the notecard.
162    pub name: String,
163    /// when the notecard was saved.
164    pub created_at: DateTime<Utc>,
165    /// the region name of the route's first waypoint, if the notecard
166    /// body parses and has at least one waypoint.
167    pub start_region: Option<String>,
168    /// the region name of the route's last waypoint, if the notecard
169    /// body parses and has at least one waypoint.
170    pub end_region: Option<String>,
171    /// number of waypoints in the route, if the notecard body parses.
172    pub waypoint_count: Option<u32>,
173    /// lower-left x grid coordinate of the route's bounding box, if it
174    /// has been resolved by a previous render run.
175    pub lower_left_x: Option<u16>,
176    /// lower-left y grid coordinate of the route's bounding box.
177    pub lower_left_y: Option<u16>,
178    /// upper-right x grid coordinate of the route's bounding box.
179    pub upper_right_x: Option<u16>,
180    /// upper-right y grid coordinate of the route's bounding box.
181    pub upper_right_y: Option<u16>,
182}
183
184/// Public, serializable record of a saved render.
185#[derive(Debug, Clone, Serialize)]
186pub struct RenderView {
187    /// the render's identifier.
188    pub render_id: Uuid,
189    /// the destination the render belongs to.
190    pub destination: Destination,
191    /// the avatar that started the render, or `None` if that account
192    /// has since been deleted (the FK is `ON DELETE SET NULL`).
193    pub created_by: Option<Uuid>,
194    /// the creator's username, if the account still exists.
195    pub created_by_username: Option<String>,
196    /// the creator's legacy name, if the account still exists.
197    pub created_by_legacy_name: Option<String>,
198    /// the linked saved notecard, if any (USB-notecard renders only).
199    pub notecard_id: Option<Uuid>,
200    /// the display name of the linked saved notecard, if any. Mirrors
201    /// `notecard_id` — both are `Some` for USB-notecard renders and both
202    /// are `None` for grid renders. (The `ON DELETE RESTRICT` on the FK
203    /// means a notecard cannot be deleted while a render references it,
204    /// so the name is always resolvable when the id is set.)
205    pub notecard_name: Option<String>,
206    /// what the render was launched from.
207    pub kind: String,
208    /// current status: `in_progress`, `done`, or `failed`.
209    pub status: String,
210    /// error message if `status == "failed"`.
211    pub error_message: Option<String>,
212    /// when the row was created (submit time).
213    pub created_at: DateTime<Utc>,
214    /// when the row reached a terminal state.
215    pub finished_at: Option<DateTime<Utc>>,
216    /// whether a without-route variant is available for download.
217    pub has_without_route: bool,
218    /// the content type of the stored image.
219    pub content_type: Option<String>,
220    /// lower-left x grid coordinate of the rendered rectangle, if known.
221    /// Always set for grid-rectangle renders; set for usb-notecard renders
222    /// once the background job has resolved the notecard's region names.
223    pub lower_left_x: Option<u16>,
224    /// lower-left y grid coordinate of the rendered rectangle, if known.
225    pub lower_left_y: Option<u16>,
226    /// upper-right x grid coordinate of the rendered rectangle, if known.
227    pub upper_right_x: Option<u16>,
228    /// upper-right y grid coordinate of the rendered rectangle, if known.
229    pub upper_right_y: Option<u16>,
230    /// the linked saved GLW data row, if any. `Some` for renders
231    /// produced with a GLW overlay; `None` for plain renders. The
232    /// `ON DELETE RESTRICT` on the FK means a GLW row cannot be
233    /// deleted while a render references it, so the name is always
234    /// resolvable when the id is set.
235    pub glw_data_id: Option<Uuid>,
236    /// the display name of the linked GLW data row, mirroring
237    /// [`Self::glw_data_id`].
238    pub glw_data_name: Option<String>,
239}
240
241/// Verify that the calling user is allowed to *write* to the given
242/// destination. Personal scope is always allowed; group scope requires
243/// owner membership.
244///
245/// # Errors
246///
247/// Returns [`Error::Forbidden`] if the user is not an owner of the target
248/// group, [`Error::NotFound`] if the group does not exist.
249pub 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
270/// Resolve a destination to whether the current user can *view* its
271/// contents and (for groups) what role they have. Personal scope means the
272/// user is the owner; otherwise [`Error::Forbidden`] is returned.
273///
274/// # Errors
275///
276/// Returns [`Error::Forbidden`] if the user is not a member of the target
277/// group.
278pub 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
300/// Convert a `(owner_user_id, owner_group_id)` row pair (exactly one of the
301/// two is Some) into a [`Destination`].
302///
303/// # Errors
304///
305/// Returns [`Error::Database`] if both or neither are set (the DB CHECK
306/// constraint should prevent this, but we still surface a clear error).
307pub 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
327/// Permission gate for reading a notecard. Personal: must be the owner.
328/// Group: must be a member.
329///
330/// # Errors
331///
332/// Returns [`Error::NotFound`] if the notecard does not exist or is not
333/// visible to the caller — the two cases are collapsed so an attacker
334/// holding a guessed id cannot confirm existence.
335pub 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
358/// Permission gate for reading a render. Personal: must be the owner.
359/// Group: must be a member; members may not see `in_progress` or `failed`
360/// renders.
361///
362/// # Errors
363///
364/// Returns [`Error::NotFound`] if the render does not exist or is not
365/// visible to the caller — the two cases are collapsed so the in-progress
366/// state of a render the caller cannot yet see is not leaked.
367pub 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
394/// Permission gate for deleting a render. Personal: must be the owner.
395/// Group: must be an owner of the group.
396///
397/// # Errors
398///
399/// Returns [`Error::Forbidden`] if the user lacks delete permission;
400/// [`Error::NotFound`] if the render does not exist.
401pub 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
432/// Permission gate for deleting a notecard (same rule as renders).
433///
434/// # Errors
435///
436/// Returns [`Error::Forbidden`] if the user lacks delete permission;
437/// [`Error::NotFound`] if the notecard does not exist.
438pub 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/// Raw row fields for a saved notecard as fetched from the DB.
470#[derive(Debug, Clone)]
471pub struct NotecardRow {
472    /// the notecard id.
473    pub notecard_id: Uuid,
474    /// raw bytes of the personal owner column, if any.
475    pub owner_user_id: Option<Vec<u8>>,
476    /// raw bytes of the group owner column, if any.
477    pub owner_group_id: Option<Vec<u8>>,
478    /// the uploading avatar id, or `None` if the uploader has since
479    /// deleted their account (FK is `ON DELETE SET NULL`).
480    pub uploaded_by: Option<Uuid>,
481    /// the notecard's display name.
482    pub name: String,
483    /// the raw notecard body (the text the user uploaded).
484    pub body: String,
485    /// when the row was created.
486    pub created_at: DateTime<Utc>,
487    /// lower-left x grid coordinate of the route's bounding box, if a
488    /// previous render has resolved and cached it.
489    pub lower_left_x: Option<u16>,
490    /// lower-left y grid coordinate of the route's bounding box.
491    pub lower_left_y: Option<u16>,
492    /// upper-right x grid coordinate of the route's bounding box.
493    pub upper_right_x: Option<u16>,
494    /// upper-right y grid coordinate of the route's bounding box.
495    pub upper_right_y: Option<u16>,
496}
497
498/// Raw row fields for a saved render as fetched from the DB.
499#[derive(Debug, Clone)]
500pub struct RenderRow {
501    /// the render id.
502    pub render_id: Uuid,
503    /// raw bytes of the personal owner column, if any.
504    pub owner_user_id: Option<Vec<u8>>,
505    /// raw bytes of the group owner column, if any.
506    pub owner_group_id: Option<Vec<u8>>,
507    /// the avatar that created the render, or `None` if the creator
508    /// has since deleted their account (FK is `ON DELETE SET NULL`).
509    pub created_by: Option<Uuid>,
510    /// the linked notecard id, if any.
511    pub notecard_id: Option<Uuid>,
512    /// the render kind: `grid_rectangle` or `usb_notecard`.
513    pub kind: String,
514    /// the current status.
515    pub status: String,
516    /// the error message if status == "failed".
517    pub error_message: Option<String>,
518    /// the settings JSON used to launch the render.
519    pub settings_json: String,
520    /// the metadata JSON produced by the render (if `done`).
521    pub metadata_json: Option<String>,
522    /// the content type of the stored image.
523    pub content_type: Option<String>,
524    /// the filename of the primary image file under `<storage_dir>/renders/`.
525    pub image_filename: Option<String>,
526    /// the filename of the without-route variant, if any.
527    pub image_without_route_filename: Option<String>,
528    /// when the row was created.
529    pub created_at: DateTime<Utc>,
530    /// when the row reached a terminal state.
531    pub finished_at: Option<DateTime<Utc>>,
532    /// lower-left x grid coordinate of the rendered rectangle, if known.
533    pub lower_left_x: Option<u16>,
534    /// lower-left y grid coordinate of the rendered rectangle, if known.
535    pub lower_left_y: Option<u16>,
536    /// upper-right x grid coordinate of the rendered rectangle, if known.
537    pub upper_right_x: Option<u16>,
538    /// upper-right y grid coordinate of the rendered rectangle, if known.
539    pub upper_right_y: Option<u16>,
540    /// the linked saved_glw_data row id, if any.
541    pub glw_data_id: Option<Uuid>,
542}
543
544/// Tuple shape returned by the `saved_notecards` lookup query.
545type 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
558/// Fetch a notecard row by id; returns [`Error::NotFound`] if missing.
559async 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/// Row shape returned by the `saved_renders` lookup query. A `FromRow`
610/// struct is used instead of a tuple because the column list exceeds
611/// sqlx's tuple-`FromRow` arity (16).
612#[derive(sqlx::FromRow)]
613struct RenderRowDb {
614    /// raw bytes of the personal owner column, if any.
615    owner_user_id: Option<Vec<u8>>,
616    /// raw bytes of the group owner column, if any.
617    owner_group_id: Option<Vec<u8>>,
618    /// raw bytes of the user that created the render. NULL when the
619    /// account has been deleted.
620    created_by: Option<Vec<u8>>,
621    /// raw bytes of the linked notecard id, if any.
622    notecard_id: Option<Vec<u8>>,
623    /// render kind (`grid_rectangle` or `usb_notecard`).
624    kind: String,
625    /// render status (`in_progress`, `done`, `failed`).
626    status: String,
627    /// error message if `status = 'failed'`.
628    error_message: Option<String>,
629    /// settings JSON used to launch the render.
630    settings_json: String,
631    /// metadata JSON produced by the render, if `done`.
632    metadata_json: Option<String>,
633    /// MIME type of the stored image, if any.
634    content_type: Option<String>,
635    /// filename of the primary image file, if any.
636    image_filename: Option<String>,
637    /// filename of the without-route image, if any.
638    image_without_route_filename: Option<String>,
639    /// row creation timestamp.
640    created_at: DateTime<Utc>,
641    /// terminal-state timestamp, if any.
642    finished_at: Option<DateTime<Utc>>,
643    /// lower-left x grid coordinate of the rendered rectangle, if known.
644    lower_left_x: Option<i64>,
645    /// lower-left y grid coordinate of the rendered rectangle, if known.
646    lower_left_y: Option<i64>,
647    /// upper-right x grid coordinate of the rendered rectangle, if known.
648    upper_right_x: Option<i64>,
649    /// upper-right y grid coordinate of the rendered rectangle, if known.
650    upper_right_y: Option<i64>,
651    /// raw bytes of the linked saved_glw_data row, if any.
652    glw_data_id: Option<Vec<u8>>,
653}
654
655/// Fetch a render row by id; returns [`Error::NotFound`] if missing.
656async 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
746/// Mark every `saved_renders` row still in `status = 'in_progress'` as
747/// `failed`. Run once at server startup, **before** the HTTP listener
748/// accepts connections: anything found in `in_progress` at that moment
749/// is orphaned by definition — the tokio task that could have
750/// transitioned it died with the previous process. Without this sweep
751/// each abandoned row would permanently count against
752/// `MAX_CONCURRENT_RENDERS_PER_USER`.
753///
754/// Single-instance deployment is assumed; SQLite's file-locking model
755/// already precludes multi-process operation, so there is no risk of
756/// marking a peer's actively rendering rows as failed.
757///
758/// Returns the number of rows recovered (zero is a valid, common
759/// result).
760///
761/// # Errors
762///
763/// Returns [`Error::Database`] on UPDATE failure.
764pub 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
782/// Run the orphan-file sweeper. Wakes every `period` seconds; if the dirty
783/// flag is unset, the tick is a cheap no-op. When the flag is set the
784/// sweeper scans `<storage_dir>/renders/` and unlinks any file whose UUID is
785/// not present in `saved_renders`. A scan failure re-raises the flag so the
786/// next tick retries.
787pub 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
814/// One pass of the sweeper: list files, query live ids, unlink the
815/// difference for both the `renders/` and `logos/` subdirectories. Returns
816/// the total number of files unlinked.
817async 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
823/// Remove orphaned files under `renders/` (no matching `saved_renders` row).
824async 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
854/// Remove orphaned files under `logos/` (no matching `saved_logos` row).
855async 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// ---------------------------------------------------------------------
886// Saved GLW data (saved_glw_data).
887//
888// Single-tier storage: the resolved GLW JSON event lives inline in the
889// `payload_json` TEXT column. Ownership uses the same dual
890// owner_user_id/owner_group_id XOR pattern as saved_notecards and
891// saved_renders. A render that uses GLW carries a `glw_data_id` FK
892// back to its source row.
893// ---------------------------------------------------------------------
894
895/// Where a saved GLW row originally came from. Persisted as the
896/// `source_kind` text column. Surfaced in the library list so the user
897/// can tell pasted JSON from a real id/key fetch.
898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
899#[serde(rename_all = "snake_case")]
900pub enum GlwDataSourceKind {
901    /// Fetched from the GLW server by numeric event id.
902    EventId,
903    /// Fetched from the GLW server by string event key.
904    EventKey,
905    /// Pasted in by the user (advanced/dev path).
906    PastedJson,
907}
908
909impl GlwDataSourceKind {
910    /// Database column representation.
911    #[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    /// Parse the database column back into the enum. Returns `None`
921    /// for any value that does not match the schema's CHECK constraint.
922    #[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/// Raw row fields for a saved GLW event as fetched from the DB.
934#[derive(Debug, Clone)]
935pub struct GlwDataRow {
936    /// the GLW data row id.
937    pub glw_data_id: Uuid,
938    /// raw bytes of the personal owner column, if any.
939    pub owner_user_id: Option<Vec<u8>>,
940    /// raw bytes of the group owner column, if any.
941    pub owner_group_id: Option<Vec<u8>>,
942    /// the avatar that created the row.
943    pub created_by: Option<Uuid>,
944    /// the human-supplied display name.
945    pub name: String,
946    /// where the event originally came from.
947    pub source_kind: GlwDataSourceKind,
948    /// originating numeric event id, when `source_kind = EventId`.
949    pub source_event_id: Option<u32>,
950    /// originating string event key, when `source_kind = EventKey`.
951    pub source_event_key: Option<String>,
952    /// raw JSON payload — parse back into `sl_glw::GlwEvent` at render
953    /// time.
954    pub payload_json: String,
955    /// numeric event id of the resolved event (from the JSON itself).
956    pub event_id: Option<u32>,
957    /// string event key of the resolved event (from the JSON itself).
958    pub event_key: Option<String>,
959    /// human-readable event name (from the JSON itself).
960    pub event_name: Option<String>,
961    /// when the event was fetched / pasted.
962    pub fetched_at: DateTime<Utc>,
963    /// when the row was created.
964    pub created_at: DateTime<Utc>,
965}
966
967/// Public, serializable record of a saved GLW event. Excludes the raw
968/// `payload_json` blob (which is large and only the render worker needs
969/// it) so the library list stays small over the wire.
970#[derive(Debug, Clone, Serialize)]
971pub struct GlwDataView {
972    /// the GLW data row id.
973    pub glw_data_id: Uuid,
974    /// the destination the row belongs to.
975    pub destination: Destination,
976    /// the avatar that created the row, or `None` if the account is
977    /// since deleted.
978    pub created_by: Option<Uuid>,
979    /// the creator's username, if the account still exists.
980    pub created_by_username: Option<String>,
981    /// the creator's legacy name, if the account still exists.
982    pub created_by_legacy_name: Option<String>,
983    /// the human-supplied display name.
984    pub name: String,
985    /// where the event originally came from.
986    pub source_kind: GlwDataSourceKind,
987    /// originating numeric event id, when `source_kind = EventId`.
988    pub source_event_id: Option<u32>,
989    /// originating string event key, when `source_kind = EventKey`.
990    pub source_event_key: Option<String>,
991    /// numeric event id of the resolved event.
992    pub event_id: Option<u32>,
993    /// string event key of the resolved event.
994    pub event_key: Option<String>,
995    /// human-readable event name.
996    pub event_name: Option<String>,
997    /// when the event was fetched / pasted.
998    pub fetched_at: DateTime<Utc>,
999    /// when the row was created.
1000    pub created_at: DateTime<Utc>,
1001}
1002
1003/// Column tuple shape returned by the GLW row SELECT. Split out so the
1004/// fetch helper does not exceed sqlx's tuple-`FromRow` arity.
1005type GlwDataRowTuple = (
1006    Option<Vec<u8>>, // owner_user_id
1007    Option<Vec<u8>>, // owner_group_id
1008    Option<Vec<u8>>, // created_by
1009    String,          // name
1010    String,          // source_kind
1011    Option<i64>,     // source_event_id
1012    Option<String>,  // source_event_key
1013    String,          // payload_json
1014    Option<i64>,     // event_id
1015    Option<String>,  // event_key
1016    Option<String>,  // event_name
1017    DateTime<Utc>,   // fetched_at
1018    DateTime<Utc>,   // created_at
1019);
1020
1021/// Fetch a GLW data row by id; returns [`Error::NotFound`] if missing.
1022async 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
1083/// Permission gate for reading a GLW data row. Personal: must be the
1084/// owner. Group: must be a member of the owning group.
1085///
1086/// # Errors
1087///
1088/// Returns [`Error::NotFound`] when the row is missing or invisible —
1089/// the two cases are collapsed so an attacker cannot confirm existence
1090/// by id.
1091pub 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
1114/// Permission gate for deleting a GLW data row. Personal: must be the
1115/// owner. Group: must be an owner of the group.
1116///
1117/// The FK `saved_renders.glw_data_id` is `ON DELETE RESTRICT`, so a
1118/// row with at least one referencing render will fail the DELETE with
1119/// a SQLite constraint violation; the route handler maps that to a
1120/// human-readable error.
1121///
1122/// # Errors
1123///
1124/// Returns [`Error::Forbidden`] if the user lacks delete permission;
1125/// [`Error::NotFound`] if the row does not exist.
1126pub 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// ---------------------------------------------------------------------
1158// Saved logos (saved_logos).
1159//
1160// Uploaded logo images stored as files under `<storage_dir>/logos/`; the
1161// DB row carries only the filename, MIME type and intrinsic dimensions.
1162// Ownership uses the same dual owner_user_id/owner_group_id XOR pattern as
1163// the other library item types. Renders that composite a logo carry a
1164// `saved_render_logos` link row back to it.
1165// ---------------------------------------------------------------------
1166
1167/// Raw row fields for a saved logo as fetched from the DB.
1168#[derive(Debug, Clone)]
1169pub struct LogoRow {
1170    /// the logo id.
1171    pub logo_id: Uuid,
1172    /// raw bytes of the personal owner column, if any.
1173    pub owner_user_id: Option<Vec<u8>>,
1174    /// raw bytes of the group owner column, if any.
1175    pub owner_group_id: Option<Vec<u8>>,
1176    /// the uploading avatar id, or `None` if the uploader has since
1177    /// deleted their account (FK is `ON DELETE SET NULL`).
1178    pub uploaded_by: Option<Uuid>,
1179    /// the human-supplied display name.
1180    pub name: String,
1181    /// MIME type of the stored bytes (`image/png` / `image/jpeg` / `image/webp`).
1182    pub content_type: String,
1183    /// relative filename under `<storage_dir>/logos/`.
1184    pub image_filename: String,
1185    /// intrinsic image width in pixels.
1186    pub width: u32,
1187    /// intrinsic image height in pixels.
1188    pub height: u32,
1189    /// size of the stored bytes.
1190    pub byte_size: u64,
1191    /// when the row was created.
1192    pub created_at: DateTime<Utc>,
1193}
1194
1195/// Public, serializable record of a saved logo. Excludes the raw bytes
1196/// (downloaded separately via `GET /api/logos/{id}/image`).
1197#[derive(Debug, Clone, Serialize)]
1198pub struct LogoView {
1199    /// the logo id.
1200    pub logo_id: Uuid,
1201    /// the destination the logo belongs to.
1202    pub destination: Destination,
1203    /// the avatar that uploaded the logo, or `None` if the account is
1204    /// since deleted.
1205    pub uploaded_by: Option<Uuid>,
1206    /// the uploader's username, if the account still exists.
1207    pub uploaded_by_username: Option<String>,
1208    /// the uploader's legacy name, if the account still exists.
1209    pub uploaded_by_legacy_name: Option<String>,
1210    /// the human-supplied display name.
1211    pub name: String,
1212    /// MIME type of the stored bytes.
1213    pub content_type: String,
1214    /// intrinsic image width in pixels.
1215    pub width: u32,
1216    /// intrinsic image height in pixels.
1217    pub height: u32,
1218    /// size of the stored bytes.
1219    pub byte_size: u64,
1220    /// when the logo was uploaded.
1221    pub created_at: DateTime<Utc>,
1222}
1223
1224/// Tuple shape returned by the `saved_logos` lookup query.
1225type LogoRowTuple = (
1226    Option<Vec<u8>>, // owner_user_id
1227    Option<Vec<u8>>, // owner_group_id
1228    Option<Vec<u8>>, // uploaded_by
1229    String,          // name
1230    String,          // content_type
1231    String,          // image_filename
1232    i64,             // width
1233    i64,             // height
1234    i64,             // byte_size
1235    DateTime<Utc>,   // created_at
1236);
1237
1238/// Fetch a logo row by id; returns [`Error::NotFound`] if missing.
1239async 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
1289/// Permission gate for reading a logo. Personal: must be the owner.
1290/// Group: must be a member of the owning group.
1291///
1292/// # Errors
1293///
1294/// Returns [`Error::NotFound`] when the row is missing or invisible — the
1295/// two cases are collapsed so an attacker cannot confirm existence by id.
1296pub 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
1319/// Permission gate for deleting a logo. Personal: must be the owner.
1320/// Group: must be an owner of the group.
1321///
1322/// The FK `saved_render_logos.logo_id` is `ON DELETE RESTRICT`, so a logo
1323/// referenced by any render fails the DELETE with a SQLite constraint
1324/// violation; the route handler maps that to a human-readable error.
1325///
1326/// # Errors
1327///
1328/// Returns [`Error::Forbidden`] if the user lacks delete permission;
1329/// [`Error::NotFound`] if the row does not exist.
1330pub 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}