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/// True if `s` is canonical `#rrggbb` — exactly one leading `#` followed
146/// by exactly six ASCII hex digits (case-insensitive). Shared by every
147/// endpoint that validates a colour swatch on the way in (the route-colour
148/// preference, the saved-colour palette, and saved themes).
149#[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/// Public, serializable record of a saved notecard.
166#[derive(Debug, Clone, Serialize)]
167pub struct NotecardView {
168    /// the notecard's identifier.
169    pub notecard_id: Uuid,
170    /// the destination the notecard belongs to.
171    pub destination: Destination,
172    /// the avatar that uploaded the notecard, or `None` if that
173    /// account has since been deleted (the FK is `ON DELETE SET NULL`,
174    /// so audit history survives the account removal but the link is
175    /// severed).
176    pub uploaded_by: Option<Uuid>,
177    /// the uploader's username, if the account still exists.
178    pub uploaded_by_username: Option<String>,
179    /// the uploader's legacy name, if the account still exists.
180    pub uploaded_by_legacy_name: Option<String>,
181    /// the human-supplied display name of the notecard.
182    pub name: String,
183    /// when the notecard was saved.
184    pub created_at: DateTime<Utc>,
185    /// the region name of the route's first waypoint, if the notecard
186    /// body parses and has at least one waypoint.
187    pub start_region: Option<String>,
188    /// the region name of the route's last waypoint, if the notecard
189    /// body parses and has at least one waypoint.
190    pub end_region: Option<String>,
191    /// number of waypoints in the route, if the notecard body parses.
192    pub waypoint_count: Option<u32>,
193    /// lower-left x grid coordinate of the route's bounding box, if it
194    /// has been resolved by a previous render run.
195    pub lower_left_x: Option<u16>,
196    /// lower-left y grid coordinate of the route's bounding box.
197    pub lower_left_y: Option<u16>,
198    /// upper-right x grid coordinate of the route's bounding box.
199    pub upper_right_x: Option<u16>,
200    /// upper-right y grid coordinate of the route's bounding box.
201    pub upper_right_y: Option<u16>,
202}
203
204/// Public, serializable record of a saved render.
205#[derive(Debug, Clone, Serialize)]
206pub struct RenderView {
207    /// the render's identifier.
208    pub render_id: Uuid,
209    /// the destination the render belongs to.
210    pub destination: Destination,
211    /// the avatar that started the render, or `None` if that account
212    /// has since been deleted (the FK is `ON DELETE SET NULL`).
213    pub created_by: Option<Uuid>,
214    /// the creator's username, if the account still exists.
215    pub created_by_username: Option<String>,
216    /// the creator's legacy name, if the account still exists.
217    pub created_by_legacy_name: Option<String>,
218    /// the linked saved notecard, if any (USB-notecard renders only).
219    pub notecard_id: Option<Uuid>,
220    /// the display name of the linked saved notecard, if any. Mirrors
221    /// `notecard_id` — both are `Some` for USB-notecard renders and both
222    /// are `None` for grid renders. (The `ON DELETE RESTRICT` on the FK
223    /// means a notecard cannot be deleted while a render references it,
224    /// so the name is always resolvable when the id is set.)
225    pub notecard_name: Option<String>,
226    /// what the render was launched from.
227    pub kind: String,
228    /// current status: `in_progress`, `done`, or `failed`.
229    pub status: String,
230    /// error message if `status == "failed"`.
231    pub error_message: Option<String>,
232    /// when the row was created (submit time).
233    pub created_at: DateTime<Utc>,
234    /// when the row reached a terminal state.
235    pub finished_at: Option<DateTime<Utc>>,
236    /// whether a without-route variant is available for download.
237    pub has_without_route: bool,
238    /// the content type of the stored image.
239    pub content_type: Option<String>,
240    /// lower-left x grid coordinate of the rendered rectangle, if known.
241    /// Always set for grid-rectangle renders; set for usb-notecard renders
242    /// once the background job has resolved the notecard's region names.
243    pub lower_left_x: Option<u16>,
244    /// lower-left y grid coordinate of the rendered rectangle, if known.
245    pub lower_left_y: Option<u16>,
246    /// upper-right x grid coordinate of the rendered rectangle, if known.
247    pub upper_right_x: Option<u16>,
248    /// upper-right y grid coordinate of the rendered rectangle, if known.
249    pub upper_right_y: Option<u16>,
250    /// the linked saved GLW data row, if any. `Some` for renders
251    /// produced with a GLW overlay; `None` for plain renders. The
252    /// `ON DELETE RESTRICT` on the FK means a GLW row cannot be
253    /// deleted while a render references it, so the name is always
254    /// resolvable when the id is set.
255    pub glw_data_id: Option<Uuid>,
256    /// the display name of the linked GLW data row, mirroring
257    /// [`Self::glw_data_id`].
258    pub glw_data_name: Option<String>,
259}
260
261/// Verify that the calling user is allowed to *write* to the given
262/// destination. Personal scope is always allowed; group scope requires
263/// owner membership.
264///
265/// # Errors
266///
267/// Returns [`Error::Forbidden`] if the user is not an owner of the target
268/// group, [`Error::NotFound`] if the group does not exist.
269pub 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
290/// Resolve a destination to whether the current user can *view* its
291/// contents and (for groups) what role they have. Personal scope means the
292/// user is the owner; otherwise [`Error::Forbidden`] is returned.
293///
294/// # Errors
295///
296/// Returns [`Error::Forbidden`] if the user is not a member of the target
297/// group.
298pub 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
320/// Convert a `(owner_user_id, owner_group_id)` row pair (exactly one of the
321/// two is Some) into a [`Destination`].
322///
323/// # Errors
324///
325/// Returns [`Error::Database`] if both or neither are set (the DB CHECK
326/// constraint should prevent this, but we still surface a clear error).
327pub 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
347/// Permission gate for reading a notecard. Personal: must be the owner.
348/// Group: must be a member.
349///
350/// # Errors
351///
352/// Returns [`Error::NotFound`] if the notecard does not exist or is not
353/// visible to the caller — the two cases are collapsed so an attacker
354/// holding a guessed id cannot confirm existence.
355pub 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
378/// Permission gate for reading a render. Personal: must be the owner.
379/// Group: must be a member; members may not see `in_progress` or `failed`
380/// renders.
381///
382/// # Errors
383///
384/// Returns [`Error::NotFound`] if the render does not exist or is not
385/// visible to the caller — the two cases are collapsed so the in-progress
386/// state of a render the caller cannot yet see is not leaked.
387pub 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
414/// Permission gate for deleting a render. Personal: must be the owner.
415/// Group: must be an owner of the group.
416///
417/// # Errors
418///
419/// Returns [`Error::Forbidden`] if the user lacks delete permission;
420/// [`Error::NotFound`] if the render does not exist.
421pub 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
452/// Permission gate for deleting a notecard (same rule as renders).
453///
454/// # Errors
455///
456/// Returns [`Error::Forbidden`] if the user lacks delete permission;
457/// [`Error::NotFound`] if the notecard does not exist.
458pub 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/// Raw row fields for a saved notecard as fetched from the DB.
490#[derive(Debug, Clone)]
491pub struct NotecardRow {
492    /// the notecard id.
493    pub notecard_id: Uuid,
494    /// raw bytes of the personal owner column, if any.
495    pub owner_user_id: Option<Vec<u8>>,
496    /// raw bytes of the group owner column, if any.
497    pub owner_group_id: Option<Vec<u8>>,
498    /// the uploading avatar id, or `None` if the uploader has since
499    /// deleted their account (FK is `ON DELETE SET NULL`).
500    pub uploaded_by: Option<Uuid>,
501    /// the notecard's display name.
502    pub name: String,
503    /// the raw notecard body (the text the user uploaded).
504    pub body: String,
505    /// when the row was created.
506    pub created_at: DateTime<Utc>,
507    /// lower-left x grid coordinate of the route's bounding box, if a
508    /// previous render has resolved and cached it.
509    pub lower_left_x: Option<u16>,
510    /// lower-left y grid coordinate of the route's bounding box.
511    pub lower_left_y: Option<u16>,
512    /// upper-right x grid coordinate of the route's bounding box.
513    pub upper_right_x: Option<u16>,
514    /// upper-right y grid coordinate of the route's bounding box.
515    pub upper_right_y: Option<u16>,
516}
517
518/// Raw row fields for a saved render as fetched from the DB.
519#[derive(Debug, Clone)]
520pub struct RenderRow {
521    /// the render id.
522    pub render_id: Uuid,
523    /// raw bytes of the personal owner column, if any.
524    pub owner_user_id: Option<Vec<u8>>,
525    /// raw bytes of the group owner column, if any.
526    pub owner_group_id: Option<Vec<u8>>,
527    /// the avatar that created the render, or `None` if the creator
528    /// has since deleted their account (FK is `ON DELETE SET NULL`).
529    pub created_by: Option<Uuid>,
530    /// the linked notecard id, if any.
531    pub notecard_id: Option<Uuid>,
532    /// the render kind: `grid_rectangle` or `usb_notecard`.
533    pub kind: String,
534    /// the current status.
535    pub status: String,
536    /// the error message if status == "failed".
537    pub error_message: Option<String>,
538    /// the settings JSON used to launch the render.
539    pub settings_json: String,
540    /// the metadata JSON produced by the render (if `done`).
541    pub metadata_json: Option<String>,
542    /// the content type of the stored image.
543    pub content_type: Option<String>,
544    /// the filename of the primary image file under `<storage_dir>/renders/`.
545    pub image_filename: Option<String>,
546    /// the filename of the without-route variant, if any.
547    pub image_without_route_filename: Option<String>,
548    /// when the row was created.
549    pub created_at: DateTime<Utc>,
550    /// when the row reached a terminal state.
551    pub finished_at: Option<DateTime<Utc>>,
552    /// lower-left x grid coordinate of the rendered rectangle, if known.
553    pub lower_left_x: Option<u16>,
554    /// lower-left y grid coordinate of the rendered rectangle, if known.
555    pub lower_left_y: Option<u16>,
556    /// upper-right x grid coordinate of the rendered rectangle, if known.
557    pub upper_right_x: Option<u16>,
558    /// upper-right y grid coordinate of the rendered rectangle, if known.
559    pub upper_right_y: Option<u16>,
560    /// the linked saved_glw_data row id, if any.
561    pub glw_data_id: Option<Uuid>,
562}
563
564/// Tuple shape returned by the `saved_notecards` lookup query.
565type 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
578/// Fetch a notecard row by id; returns [`Error::NotFound`] if missing.
579async 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/// Row shape returned by the `saved_renders` lookup query. A `FromRow`
630/// struct is used instead of a tuple because the column list exceeds
631/// sqlx's tuple-`FromRow` arity (16).
632#[derive(sqlx::FromRow)]
633struct RenderRowDb {
634    /// raw bytes of the personal owner column, if any.
635    owner_user_id: Option<Vec<u8>>,
636    /// raw bytes of the group owner column, if any.
637    owner_group_id: Option<Vec<u8>>,
638    /// raw bytes of the user that created the render. NULL when the
639    /// account has been deleted.
640    created_by: Option<Vec<u8>>,
641    /// raw bytes of the linked notecard id, if any.
642    notecard_id: Option<Vec<u8>>,
643    /// render kind (`grid_rectangle` or `usb_notecard`).
644    kind: String,
645    /// render status (`in_progress`, `done`, `failed`).
646    status: String,
647    /// error message if `status = 'failed'`.
648    error_message: Option<String>,
649    /// settings JSON used to launch the render.
650    settings_json: String,
651    /// metadata JSON produced by the render, if `done`.
652    metadata_json: Option<String>,
653    /// MIME type of the stored image, if any.
654    content_type: Option<String>,
655    /// filename of the primary image file, if any.
656    image_filename: Option<String>,
657    /// filename of the without-route image, if any.
658    image_without_route_filename: Option<String>,
659    /// row creation timestamp.
660    created_at: DateTime<Utc>,
661    /// terminal-state timestamp, if any.
662    finished_at: Option<DateTime<Utc>>,
663    /// lower-left x grid coordinate of the rendered rectangle, if known.
664    lower_left_x: Option<i64>,
665    /// lower-left y grid coordinate of the rendered rectangle, if known.
666    lower_left_y: Option<i64>,
667    /// upper-right x grid coordinate of the rendered rectangle, if known.
668    upper_right_x: Option<i64>,
669    /// upper-right y grid coordinate of the rendered rectangle, if known.
670    upper_right_y: Option<i64>,
671    /// raw bytes of the linked saved_glw_data row, if any.
672    glw_data_id: Option<Vec<u8>>,
673}
674
675/// Fetch a render row by id; returns [`Error::NotFound`] if missing.
676async 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
766/// Mark every `saved_renders` row still in `status = 'in_progress'` as
767/// `failed`. Run once at server startup, **before** the HTTP listener
768/// accepts connections: anything found in `in_progress` at that moment
769/// is orphaned by definition — the tokio task that could have
770/// transitioned it died with the previous process. Without this sweep
771/// each abandoned row would permanently count against
772/// `MAX_CONCURRENT_RENDERS_PER_USER`.
773///
774/// Single-instance deployment is assumed; SQLite's file-locking model
775/// already precludes multi-process operation, so there is no risk of
776/// marking a peer's actively rendering rows as failed.
777///
778/// Returns the number of rows recovered (zero is a valid, common
779/// result).
780///
781/// # Errors
782///
783/// Returns [`Error::Database`] on UPDATE failure.
784pub 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
802/// Run the orphan-file sweeper. Wakes every `period` seconds; if the dirty
803/// flag is unset, the tick is a cheap no-op. When the flag is set the
804/// sweeper scans `<storage_dir>/renders/` and unlinks any file whose UUID is
805/// not present in `saved_renders`. A scan failure re-raises the flag so the
806/// next tick retries.
807pub 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
834/// One pass of the sweeper: list files, query live ids, unlink the
835/// difference for both the `renders/` and `logos/` subdirectories. Returns
836/// the total number of files unlinked.
837async 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
843/// Remove orphaned files under `renders/` (no matching `saved_renders` row).
844async 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
874/// Remove orphaned files under `logos/` (no matching `saved_logos` row).
875async 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// ---------------------------------------------------------------------
906// Saved GLW data (saved_glw_data).
907//
908// Single-tier storage: the resolved GLW JSON event lives inline in the
909// `payload_json` TEXT column. Ownership uses the same dual
910// owner_user_id/owner_group_id XOR pattern as saved_notecards and
911// saved_renders. A render that uses GLW carries a `glw_data_id` FK
912// back to its source row.
913// ---------------------------------------------------------------------
914
915/// Where a saved GLW row originally came from. Persisted as the
916/// `source_kind` text column. Surfaced in the library list so the user
917/// can tell pasted JSON from a real id/key fetch.
918#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
919#[serde(rename_all = "snake_case")]
920pub enum GlwDataSourceKind {
921    /// Fetched from the GLW server by numeric event id.
922    EventId,
923    /// Fetched from the GLW server by string event key.
924    EventKey,
925    /// Pasted in by the user (advanced/dev path).
926    PastedJson,
927}
928
929impl GlwDataSourceKind {
930    /// Database column representation.
931    #[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    /// Parse the database column back into the enum. Returns `None`
941    /// for any value that does not match the schema's CHECK constraint.
942    #[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/// Raw row fields for a saved GLW event as fetched from the DB.
954#[derive(Debug, Clone)]
955pub struct GlwDataRow {
956    /// the GLW data row id.
957    pub glw_data_id: Uuid,
958    /// raw bytes of the personal owner column, if any.
959    pub owner_user_id: Option<Vec<u8>>,
960    /// raw bytes of the group owner column, if any.
961    pub owner_group_id: Option<Vec<u8>>,
962    /// the avatar that created the row.
963    pub created_by: Option<Uuid>,
964    /// the human-supplied display name.
965    pub name: String,
966    /// where the event originally came from.
967    pub source_kind: GlwDataSourceKind,
968    /// originating numeric event id, when `source_kind = EventId`.
969    pub source_event_id: Option<u32>,
970    /// originating string event key, when `source_kind = EventKey`.
971    pub source_event_key: Option<String>,
972    /// raw JSON payload — parse back into `sl_glw::GlwEvent` at render
973    /// time.
974    pub payload_json: String,
975    /// numeric event id of the resolved event (from the JSON itself).
976    pub event_id: Option<u32>,
977    /// string event key of the resolved event (from the JSON itself).
978    pub event_key: Option<String>,
979    /// human-readable event name (from the JSON itself).
980    pub event_name: Option<String>,
981    /// when the event was fetched / pasted.
982    pub fetched_at: DateTime<Utc>,
983    /// when the row was created.
984    pub created_at: DateTime<Utc>,
985}
986
987/// Public, serializable record of a saved GLW event. Excludes the raw
988/// `payload_json` blob (which is large and only the render worker needs
989/// it) so the library list stays small over the wire.
990#[derive(Debug, Clone, Serialize)]
991pub struct GlwDataView {
992    /// the GLW data row id.
993    pub glw_data_id: Uuid,
994    /// the destination the row belongs to.
995    pub destination: Destination,
996    /// the avatar that created the row, or `None` if the account is
997    /// since deleted.
998    pub created_by: Option<Uuid>,
999    /// the creator's username, if the account still exists.
1000    pub created_by_username: Option<String>,
1001    /// the creator's legacy name, if the account still exists.
1002    pub created_by_legacy_name: Option<String>,
1003    /// the human-supplied display name.
1004    pub name: String,
1005    /// where the event originally came from.
1006    pub source_kind: GlwDataSourceKind,
1007    /// originating numeric event id, when `source_kind = EventId`.
1008    pub source_event_id: Option<u32>,
1009    /// originating string event key, when `source_kind = EventKey`.
1010    pub source_event_key: Option<String>,
1011    /// numeric event id of the resolved event.
1012    pub event_id: Option<u32>,
1013    /// string event key of the resolved event.
1014    pub event_key: Option<String>,
1015    /// human-readable event name.
1016    pub event_name: Option<String>,
1017    /// when the event was fetched / pasted.
1018    pub fetched_at: DateTime<Utc>,
1019    /// when the row was created.
1020    pub created_at: DateTime<Utc>,
1021}
1022
1023/// Column tuple shape returned by the GLW row SELECT. Split out so the
1024/// fetch helper does not exceed sqlx's tuple-`FromRow` arity.
1025type GlwDataRowTuple = (
1026    Option<Vec<u8>>, // owner_user_id
1027    Option<Vec<u8>>, // owner_group_id
1028    Option<Vec<u8>>, // created_by
1029    String,          // name
1030    String,          // source_kind
1031    Option<i64>,     // source_event_id
1032    Option<String>,  // source_event_key
1033    String,          // payload_json
1034    Option<i64>,     // event_id
1035    Option<String>,  // event_key
1036    Option<String>,  // event_name
1037    DateTime<Utc>,   // fetched_at
1038    DateTime<Utc>,   // created_at
1039);
1040
1041/// Fetch a GLW data row by id; returns [`Error::NotFound`] if missing.
1042async 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
1103/// Permission gate for reading a GLW data row. Personal: must be the
1104/// owner. Group: must be a member of the owning group.
1105///
1106/// # Errors
1107///
1108/// Returns [`Error::NotFound`] when the row is missing or invisible —
1109/// the two cases are collapsed so an attacker cannot confirm existence
1110/// by id.
1111pub 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
1134/// Permission gate for deleting a GLW data row. Personal: must be the
1135/// owner. Group: must be an owner of the group.
1136///
1137/// The FK `saved_renders.glw_data_id` is `ON DELETE RESTRICT`, so a
1138/// row with at least one referencing render will fail the DELETE with
1139/// a SQLite constraint violation; the route handler maps that to a
1140/// human-readable error.
1141///
1142/// # Errors
1143///
1144/// Returns [`Error::Forbidden`] if the user lacks delete permission;
1145/// [`Error::NotFound`] if the row does not exist.
1146pub 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// ---------------------------------------------------------------------
1178// Saved themes (themes).
1179//
1180// A named bundle of the render page's presentation settings. Ownership uses
1181// the same dual owner_user_id/owner_group_id XOR pattern as the other
1182// library item types. The settings payload itself lives in `settings_json`
1183// and is opaque at this layer (parsed by `routes::themes::ThemeSettings`).
1184// ---------------------------------------------------------------------
1185
1186/// Raw row fields for a saved theme as fetched from the DB.
1187#[derive(Debug, Clone)]
1188pub struct ThemeRow {
1189    /// the theme id.
1190    pub theme_id: Uuid,
1191    /// raw bytes of the personal owner column, if any.
1192    pub owner_user_id: Option<Vec<u8>>,
1193    /// raw bytes of the group owner column, if any.
1194    pub owner_group_id: Option<Vec<u8>>,
1195    /// the avatar that created the theme, or `None` if the creator has
1196    /// since deleted their account (FK is `ON DELETE SET NULL`).
1197    pub created_by: Option<Uuid>,
1198    /// the human-supplied display name.
1199    pub name: String,
1200    /// the presentation settings as canonical JSON.
1201    pub settings_json: String,
1202    /// when the row was created.
1203    pub created_at: DateTime<Utc>,
1204    /// when the row was last renamed or its settings overwritten.
1205    pub updated_at: DateTime<Utc>,
1206}
1207
1208/// Column tuple shape returned by the theme row SELECT.
1209type ThemeRowTuple = (
1210    Option<Vec<u8>>, // owner_user_id
1211    Option<Vec<u8>>, // owner_group_id
1212    Option<Vec<u8>>, // created_by
1213    String,          // name
1214    String,          // settings_json
1215    DateTime<Utc>,   // created_at
1216    DateTime<Utc>,   // updated_at
1217);
1218
1219/// Fetch a theme row by id; returns [`Error::NotFound`] if missing.
1220async 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
1264/// Permission gate for reading a theme. Personal: must be the owner.
1265/// Group: must be a member of the owning group.
1266///
1267/// # Errors
1268///
1269/// Returns [`Error::NotFound`] when the row is missing or invisible — the
1270/// two cases are collapsed so an attacker cannot confirm existence by id.
1271pub 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
1294/// Permission gate for modifying (renaming / overwriting / deleting) a
1295/// theme. Personal: must be the owner. Group: must be an owner of the
1296/// group. Mirrors [`assert_can_delete_glw_data`].
1297///
1298/// # Errors
1299///
1300/// Returns [`Error::Forbidden`] if the user lacks write permission;
1301/// [`Error::NotFound`] if the row does not exist.
1302pub 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// ---------------------------------------------------------------------
1334// Saved logos (saved_logos).
1335//
1336// Uploaded logo images stored as files under `<storage_dir>/logos/`; the
1337// DB row carries only the filename, MIME type and intrinsic dimensions.
1338// Ownership uses the same dual owner_user_id/owner_group_id XOR pattern as
1339// the other library item types. Renders that composite a logo carry a
1340// `saved_render_logos` link row back to it.
1341// ---------------------------------------------------------------------
1342
1343/// Raw row fields for a saved logo as fetched from the DB.
1344#[derive(Debug, Clone)]
1345pub struct LogoRow {
1346    /// the logo id.
1347    pub logo_id: Uuid,
1348    /// raw bytes of the personal owner column, if any.
1349    pub owner_user_id: Option<Vec<u8>>,
1350    /// raw bytes of the group owner column, if any.
1351    pub owner_group_id: Option<Vec<u8>>,
1352    /// the uploading avatar id, or `None` if the uploader has since
1353    /// deleted their account (FK is `ON DELETE SET NULL`).
1354    pub uploaded_by: Option<Uuid>,
1355    /// the human-supplied display name.
1356    pub name: String,
1357    /// MIME type of the stored bytes (`image/png` / `image/jpeg` / `image/webp`).
1358    pub content_type: String,
1359    /// relative filename under `<storage_dir>/logos/`.
1360    pub image_filename: String,
1361    /// intrinsic image width in pixels.
1362    pub width: u32,
1363    /// intrinsic image height in pixels.
1364    pub height: u32,
1365    /// size of the stored bytes.
1366    pub byte_size: u64,
1367    /// when the row was created.
1368    pub created_at: DateTime<Utc>,
1369}
1370
1371/// Public, serializable record of a saved logo. Excludes the raw bytes
1372/// (downloaded separately via `GET /api/logos/{id}/image`).
1373#[derive(Debug, Clone, Serialize)]
1374pub struct LogoView {
1375    /// the logo id.
1376    pub logo_id: Uuid,
1377    /// the destination the logo belongs to.
1378    pub destination: Destination,
1379    /// the avatar that uploaded the logo, or `None` if the account is
1380    /// since deleted.
1381    pub uploaded_by: Option<Uuid>,
1382    /// the uploader's username, if the account still exists.
1383    pub uploaded_by_username: Option<String>,
1384    /// the uploader's legacy name, if the account still exists.
1385    pub uploaded_by_legacy_name: Option<String>,
1386    /// the human-supplied display name.
1387    pub name: String,
1388    /// MIME type of the stored bytes.
1389    pub content_type: String,
1390    /// intrinsic image width in pixels.
1391    pub width: u32,
1392    /// intrinsic image height in pixels.
1393    pub height: u32,
1394    /// size of the stored bytes.
1395    pub byte_size: u64,
1396    /// when the logo was uploaded.
1397    pub created_at: DateTime<Utc>,
1398}
1399
1400/// Tuple shape returned by the `saved_logos` lookup query.
1401type LogoRowTuple = (
1402    Option<Vec<u8>>, // owner_user_id
1403    Option<Vec<u8>>, // owner_group_id
1404    Option<Vec<u8>>, // uploaded_by
1405    String,          // name
1406    String,          // content_type
1407    String,          // image_filename
1408    i64,             // width
1409    i64,             // height
1410    i64,             // byte_size
1411    DateTime<Utc>,   // created_at
1412);
1413
1414/// Fetch a logo row by id; returns [`Error::NotFound`] if missing.
1415async 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
1465/// Permission gate for reading a logo. Personal: must be the owner.
1466/// Group: must be a member of the owning group.
1467///
1468/// # Errors
1469///
1470/// Returns [`Error::NotFound`] when the row is missing or invisible — the
1471/// two cases are collapsed so an attacker cannot confirm existence by id.
1472pub 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
1495/// Permission gate for deleting a logo. Personal: must be the owner.
1496/// Group: must be an owner of the group.
1497///
1498/// The FK `saved_render_logos.logo_id` is `ON DELETE RESTRICT`, so a logo
1499/// referenced by any render fails the DELETE with a SQLite constraint
1500/// violation; the route handler maps that to a human-readable error.
1501///
1502/// # Errors
1503///
1504/// Returns [`Error::Forbidden`] if the user lacks delete permission;
1505/// [`Error::NotFound`] if the row does not exist.
1506pub 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}