Skip to main content

sl_map_web/routes/
logos.rs

1//! HTTP handlers for saved logo images.
2//!
3//! Logos are uploaded by users into a personal or group library, stored as
4//! files under `<storage_dir>/logos/`, and later composited onto a render at
5//! a free placement slot (see [`crate::routes::render`]). The CRUD routes
6//! below let users upload / list / inspect / download / rename / delete them,
7//! following the same shape as [`crate::routes::glw`] and
8//! [`crate::routes::notecards`].
9
10use axum::Json;
11use axum::extract::{Multipart, Path, Query, State};
12use axum::http::StatusCode as ReqwestStatusCode;
13use axum::http::header;
14use axum::response::{IntoResponse as _, Response};
15use chrono::{DateTime, Utc};
16use image::GenericImageView as _;
17use serde::{Deserialize, Serialize};
18use std::sync::atomic::Ordering;
19use uuid::Uuid;
20
21use crate::auth::{CurrentUser, uuid_from_bytes};
22use crate::error::{self, Error};
23use crate::library::{self, Destination, LogoView};
24use crate::state::AppState;
25use crate::storage;
26
27/// Maximum accepted size of an uploaded logo file, in bytes (5 MiB).
28const MAX_LOGO_BYTES: usize = 5 * 1024 * 1024;
29
30/// Maximum accepted width or height of an uploaded logo, in pixels. Matches
31/// the larger of the two Second Life texture sizes.
32const MAX_LOGO_DIMENSION: u32 = 2048;
33
34/// Query parameters for `GET /api/logos`.
35#[derive(Debug, Deserialize)]
36pub struct ScopeQuery {
37    /// `"personal"` or `"group:<uuid>"`.
38    pub scope: String,
39}
40
41/// Response carrying the listing of logos in a scope.
42#[derive(Debug, Serialize)]
43pub struct ListLogosResponse {
44    /// the saved logos visible in the requested scope, newest first.
45    pub logos: Vec<LogoView>,
46}
47
48/// Response carrying a single saved logo's metadata.
49#[derive(Debug, Serialize)]
50pub struct LogoResponse {
51    /// the requested logo's metadata. The bytes are downloaded separately
52    /// via `GET /api/logos/{id}/image`.
53    pub logo: LogoView,
54}
55
56/// `GET /api/logos?scope=…` — list logos in a scope.
57///
58/// # Errors
59///
60/// Returns [`Error::BadRequest`] for an invalid scope; [`Error::Forbidden`]
61/// if the user is not allowed to view that scope.
62pub async fn list(
63    user: CurrentUser,
64    State(state): State<AppState>,
65    Query(query): Query<ScopeQuery>,
66) -> Result<Json<ListLogosResponse>, Error> {
67    let destination = Destination::parse(&query.scope)?;
68    library::assert_can_view(&state.db, user.user_id, destination).await?;
69    let logos = fetch_logos_for(&state, user.user_id, destination).await?;
70    Ok(Json(ListLogosResponse { logos }))
71}
72
73/// `POST /api/logos` — upload a fresh logo. Multipart body: `file` (the
74/// image), `name`, `scope`.
75///
76/// # Errors
77///
78/// Returns [`Error::BadRequest`] for a missing/oversized/undecodable image or
79/// malformed fields; [`Error::Forbidden`] for writing to a group the user
80/// does not own.
81pub async fn create(
82    user: CurrentUser,
83    State(state): State<AppState>,
84    multipart: Multipart,
85) -> Result<(ReqwestStatusCode, Json<LogoResponse>), Error> {
86    let parsed = parse_create_form(multipart).await?;
87    library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
88
89    let logo_id = Uuid::new_v4();
90    let ext = storage::ext_for_content_type(parsed.content_type);
91    let byte_size = parsed.bytes.len();
92    let image_filename =
93        match storage::write_logo_file(&state.config.storage_dir, logo_id, ext, parsed.bytes).await
94        {
95            Ok(f) => f,
96            Err(err) => {
97                tracing::error!("write logo file failed: {err}");
98                return Err(Error::Io(std::io::Error::other("could not store logo")));
99            }
100        };
101
102    let now = Utc::now();
103    if let Err(err) = insert_logo_row(
104        &state,
105        &InsertLogo {
106            logo_id,
107            destination: parsed.destination,
108            uploaded_by: user.user_id,
109            name: &parsed.name,
110            content_type: parsed.content_type,
111            image_filename: &image_filename,
112            width: parsed.width,
113            height: parsed.height,
114            byte_size,
115            created_at: now,
116        },
117    )
118    .await
119    {
120        // The row never landed; flag the orphaned file for the sweeper.
121        state.library_cleanup_dirty.store(true, Ordering::Release);
122        return Err(err);
123    }
124
125    let view = build_view(
126        logo_id,
127        parsed.destination,
128        Some(user.user_id),
129        Some(&user.username),
130        Some(&user.legacy_name),
131        &parsed.name,
132        parsed.content_type,
133        parsed.width,
134        parsed.height,
135        byte_size,
136        now,
137    );
138    Ok((
139        ReqwestStatusCode::CREATED,
140        Json(LogoResponse { logo: view }),
141    ))
142}
143
144/// `GET /api/logos/{id}` — fetch a logo's metadata.
145///
146/// # Errors
147///
148/// Returns [`Error::NotFound`] if it does not exist or is invisible.
149pub async fn get(
150    user: CurrentUser,
151    State(state): State<AppState>,
152    Path(logo_id): Path<Uuid>,
153) -> Result<Json<LogoResponse>, Error> {
154    let row = library::assert_can_read_logo(&state.db, user.user_id, logo_id).await?;
155    let destination =
156        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
157    let (uploader_username, uploader_legacy) = match row.uploaded_by {
158        Some(id) => match lookup_user_names(&state, id).await {
159            Ok((u, l)) => (Some(u), Some(l)),
160            Err(_) => (None, None),
161        },
162        None => (None, None),
163    };
164    let view = build_view(
165        logo_id,
166        destination,
167        row.uploaded_by,
168        uploader_username.as_deref(),
169        uploader_legacy.as_deref(),
170        &row.name,
171        &row.content_type,
172        row.width,
173        row.height,
174        usize::try_from(row.byte_size).unwrap_or(usize::MAX),
175        row.created_at,
176    );
177    Ok(Json(LogoResponse { logo: view }))
178}
179
180/// `GET /api/logos/{id}/image` — serve the logo's raw image bytes inline so
181/// the library thumbnail and the map-editor picker can display them.
182///
183/// # Errors
184///
185/// Returns [`Error::NotFound`] if the logo does not exist or is invisible.
186pub async fn image(
187    user: CurrentUser,
188    State(state): State<AppState>,
189    Path(logo_id): Path<Uuid>,
190) -> Result<Response, Error> {
191    let row = library::assert_can_read_logo(&state.db, user.user_id, logo_id).await?;
192    let bytes = storage::read_logo_file(&state.config.storage_dir, &row.image_filename).await?;
193    let headers = [(header::CONTENT_TYPE, row.content_type)];
194    Ok((headers, bytes).into_response())
195}
196
197/// Body for `PATCH /api/logos/{id}` — only the display `name` can change;
198/// the bytes are immutable so any render referencing the logo keeps pointing
199/// at the same image.
200#[derive(Debug, Deserialize)]
201pub struct RenameRequest {
202    /// new display name.
203    pub name: String,
204}
205
206/// `PATCH /api/logos/{id}` — rename a saved logo. Personal owner or group
207/// owner only.
208///
209/// # Errors
210///
211/// Returns [`Error::Forbidden`] / [`Error::NotFound`] / [`Error::BadRequest`].
212pub async fn rename(
213    user: CurrentUser,
214    State(state): State<AppState>,
215    Path(logo_id): Path<Uuid>,
216    Json(body): Json<RenameRequest>,
217) -> Result<Json<LogoResponse>, Error> {
218    let trimmed = library::sanitise_display_name(&body.name, "name")?;
219    let row = library::assert_can_delete_logo(&state.db, user.user_id, logo_id).await?;
220    sqlx::query("UPDATE saved_logos SET name = ?1 WHERE logo_id = ?2")
221        .bind(&trimmed)
222        .bind(logo_id.as_bytes().to_vec())
223        .execute(&state.db)
224        .await
225        .map_err(|err| {
226            tracing::error!("logo rename failed: {err}");
227            Error::Database
228        })?;
229    let destination =
230        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
231    let (uploader_username, uploader_legacy) = match row.uploaded_by {
232        Some(id) => match lookup_user_names(&state, id).await {
233            Ok((u, l)) => (Some(u), Some(l)),
234            Err(_) => (None, None),
235        },
236        None => (None, None),
237    };
238    let view = build_view(
239        logo_id,
240        destination,
241        row.uploaded_by,
242        uploader_username.as_deref(),
243        uploader_legacy.as_deref(),
244        &trimmed,
245        &row.content_type,
246        row.width,
247        row.height,
248        usize::try_from(row.byte_size).unwrap_or(usize::MAX),
249        row.created_at,
250    );
251    Ok(Json(LogoResponse { logo: view }))
252}
253
254/// `DELETE /api/logos/{id}` — delete a saved logo. Personal owner or group
255/// owner only. The FK `saved_render_logos.logo_id` is `ON DELETE RESTRICT`,
256/// so a logo used by any render fails with a human-friendly error.
257///
258/// # Errors
259///
260/// Returns [`Error::Forbidden`] / [`Error::NotFound`] / [`Error::BadRequest`].
261pub async fn delete(
262    user: CurrentUser,
263    State(state): State<AppState>,
264    Path(logo_id): Path<Uuid>,
265) -> Result<Response, Error> {
266    let row = library::assert_can_delete_logo(&state.db, user.user_id, logo_id).await?;
267    let result = sqlx::query("DELETE FROM saved_logos WHERE logo_id = ?1")
268        .bind(logo_id.as_bytes().to_vec())
269        .execute(&state.db)
270        .await;
271    match result {
272        Ok(_) => {
273            // Best-effort unlink; a failure just leaves the file for the
274            // orphan sweeper to collect.
275            if let Err(err) =
276                storage::try_delete_logo_file(&state.config.storage_dir, &row.image_filename)
277            {
278                tracing::warn!("could not unlink logo file {}: {err}", row.image_filename);
279                state.library_cleanup_dirty.store(true, Ordering::Release);
280            }
281            Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
282        }
283        Err(err) => {
284            if error::is_fk_violation(&err) {
285                return Err(Error::BadRequest(
286                    "cannot delete this logo: one or more saved renders still reference it. \
287                     Delete those renders first."
288                        .to_owned(),
289                ));
290            }
291            tracing::error!("logo delete failed: {err}");
292            Err(Error::Database)
293        }
294    }
295}
296
297/// Parsed `POST /api/logos` form fields plus the validated image.
298struct CreateForm {
299    /// destination scope (personal or a group).
300    destination: Destination,
301    /// the human-supplied display name.
302    name: String,
303    /// canonical MIME type derived from the decoded image bytes.
304    content_type: &'static str,
305    /// intrinsic image width in pixels.
306    width: u32,
307    /// intrinsic image height in pixels.
308    height: u32,
309    /// the raw image bytes to store.
310    bytes: bytes::Bytes,
311}
312
313/// Map a decoded [`image::ImageFormat`] to the canonical stored MIME type,
314/// rejecting anything that is not one of the three accepted formats.
315fn content_type_for_format(format: image::ImageFormat) -> Result<&'static str, Error> {
316    match format {
317        image::ImageFormat::Png => Ok("image/png"),
318        image::ImageFormat::Jpeg => Ok("image/jpeg"),
319        image::ImageFormat::WebP => Ok("image/webp"),
320        other => Err(Error::BadRequest(format!(
321            "unsupported image format {other:?}; upload a PNG, JPEG or WebP"
322        ))),
323    }
324}
325
326/// Parse the multipart form for a `POST /api/logos` request, validating the
327/// uploaded image's format, size and dimensions.
328async fn parse_create_form(mut multipart: Multipart) -> Result<CreateForm, Error> {
329    let mut file_bytes: Option<bytes::Bytes> = None;
330    let mut name: Option<String> = None;
331    let mut scope_raw: Option<String> = None;
332    while let Some(field) = multipart.next_field().await? {
333        let Some(field_name) = field.name().map(str::to_owned) else {
334            continue;
335        };
336        match field_name.as_str() {
337            "file" => {
338                let bytes = field.bytes().await?;
339                if !bytes.is_empty() {
340                    file_bytes = Some(bytes);
341                }
342            }
343            "name" => {
344                let t = field.text().await?;
345                if !t.trim().is_empty() {
346                    name = Some(t);
347                }
348            }
349            "scope" => scope_raw = Some(field.text().await?),
350            _ => {}
351        }
352    }
353
354    let bytes =
355        file_bytes.ok_or_else(|| Error::BadRequest("supply a logo image file".to_owned()))?;
356    if bytes.len() > MAX_LOGO_BYTES {
357        return Err(Error::BadRequest(format!(
358            "logo image is {} bytes; the maximum is {MAX_LOGO_BYTES} bytes",
359            bytes.len()
360        )));
361    }
362    // Determine the format from the bytes themselves rather than trusting the
363    // multipart content-type header, then decode to confirm it is a valid
364    // image and to read its dimensions.
365    let format = image::guess_format(&bytes)
366        .map_err(|e| Error::BadRequest(format!("could not recognise image format: {e}")))?;
367    let content_type = content_type_for_format(format)?;
368    let decoded = image::load_from_memory_with_format(&bytes, format)
369        .map_err(|e| Error::BadRequest(format!("could not decode image: {e}")))?;
370    let (width, height) = decoded.dimensions();
371    if width == 0 || height == 0 {
372        return Err(Error::BadRequest("logo image has zero size".to_owned()));
373    }
374    if width > MAX_LOGO_DIMENSION || height > MAX_LOGO_DIMENSION {
375        return Err(Error::BadRequest(format!(
376            "logo image is {width}x{height}px; each side must be at most {MAX_LOGO_DIMENSION}px"
377        )));
378    }
379
380    let name_raw = name.ok_or_else(|| Error::BadRequest("name is required".to_owned()))?;
381    let name = library::sanitise_display_name(&name_raw, "logo name")?;
382    let destination = Destination::parse(scope_raw.as_deref().unwrap_or("personal"))?;
383    Ok(CreateForm {
384        destination,
385        name,
386        content_type,
387        width,
388        height,
389        bytes,
390    })
391}
392
393/// Fields needed to persist a fresh `saved_logos` row.
394struct InsertLogo<'a> {
395    /// the pre-generated logo id (also the on-disk filename stem).
396    logo_id: Uuid,
397    /// destination scope (personal or a group).
398    destination: Destination,
399    /// the avatar uploading the logo.
400    uploaded_by: Uuid,
401    /// the display name.
402    name: &'a str,
403    /// canonical MIME type.
404    content_type: &'a str,
405    /// relative filename under `<storage_dir>/logos/`.
406    image_filename: &'a str,
407    /// intrinsic image width in pixels.
408    width: u32,
409    /// intrinsic image height in pixels.
410    height: u32,
411    /// size of the stored bytes.
412    byte_size: usize,
413    /// row creation timestamp.
414    created_at: DateTime<Utc>,
415}
416
417/// Persist a fresh `saved_logos` row. Callers must already have permission to
418/// write to the destination.
419///
420/// # Errors
421///
422/// Returns [`Error::Database`] on any underlying SQLite failure.
423async fn insert_logo_row(state: &AppState, insert: &InsertLogo<'_>) -> Result<(), Error> {
424    let (owner_user, owner_group) = match insert.destination {
425        Destination::Personal => (Some(insert.uploaded_by.as_bytes().to_vec()), None),
426        Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
427    };
428    sqlx::query(
429        "INSERT INTO saved_logos \
430            (logo_id, owner_user_id, owner_group_id, uploaded_by, name, \
431             content_type, image_filename, width, height, byte_size, created_at) \
432         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
433    )
434    .bind(insert.logo_id.as_bytes().to_vec())
435    .bind(owner_user)
436    .bind(owner_group)
437    .bind(insert.uploaded_by.as_bytes().to_vec())
438    .bind(insert.name)
439    .bind(insert.content_type)
440    .bind(insert.image_filename)
441    .bind(i64::from(insert.width))
442    .bind(i64::from(insert.height))
443    .bind(i64::try_from(insert.byte_size).unwrap_or(i64::MAX))
444    .bind(insert.created_at)
445    .execute(&state.db)
446    .await
447    .map_err(|err| {
448        tracing::error!("insert saved_logos failed: {err}");
449        Error::Database
450    })?;
451    Ok(())
452}
453
454/// Row shape returned by the listing query.
455#[derive(sqlx::FromRow)]
456struct LogoListRow {
457    /// raw bytes of `saved_logos.logo_id`.
458    logo_id: Vec<u8>,
459    /// raw bytes of `saved_logos.owner_user_id`, if set.
460    owner_user_id: Option<Vec<u8>>,
461    /// raw bytes of `saved_logos.owner_group_id`, if set.
462    owner_group_id: Option<Vec<u8>>,
463    /// raw bytes of the uploading user's id, if the account still exists.
464    uploaded_by: Option<Vec<u8>>,
465    /// the uploading user's `username`.
466    uploader_username: Option<String>,
467    /// the uploading user's `legacy_name`.
468    uploader_legacy: Option<String>,
469    /// display name.
470    name: String,
471    /// MIME type of the stored bytes.
472    content_type: String,
473    /// intrinsic image width in pixels.
474    width: i64,
475    /// intrinsic image height in pixels.
476    height: i64,
477    /// size of the stored bytes.
478    byte_size: i64,
479    /// row creation timestamp.
480    created_at: DateTime<Utc>,
481}
482
483/// Run the list query for the given scope.
484async fn fetch_logos_for(
485    state: &AppState,
486    current_user: Uuid,
487    destination: Destination,
488) -> Result<Vec<LogoView>, Error> {
489    let rows: Vec<LogoListRow> = match destination {
490        Destination::Personal => sqlx::query_as(
491            "SELECT l.logo_id, l.owner_user_id, l.owner_group_id, l.uploaded_by AS uploaded_by, \
492                    u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
493                    l.name, l.content_type, l.width, l.height, l.byte_size, l.created_at \
494             FROM saved_logos AS l \
495             LEFT JOIN users AS u ON u.user_id = l.uploaded_by \
496             WHERE l.owner_user_id = ?1 \
497             ORDER BY l.created_at DESC",
498        )
499        .bind(current_user.as_bytes().to_vec())
500        .fetch_all(&state.db)
501        .await
502        .map_err(|err| {
503            tracing::error!("list logos (personal) failed: {err}");
504            Error::Database
505        })?,
506        Destination::Group { group_id } => sqlx::query_as(
507            // The JOIN against group_memberships enforces visibility at the
508            // SQL layer, matching the other library list queries.
509            "SELECT l.logo_id, l.owner_user_id, l.owner_group_id, l.uploaded_by AS uploaded_by, \
510                    u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
511                    l.name, l.content_type, l.width, l.height, l.byte_size, l.created_at \
512             FROM saved_logos AS l \
513             LEFT JOIN users AS u ON u.user_id = l.uploaded_by \
514             JOIN group_memberships AS gm \
515               ON gm.group_id = l.owner_group_id AND gm.user_id = ?2 \
516             WHERE l.owner_group_id = ?1 \
517             ORDER BY l.created_at DESC",
518        )
519        .bind(group_id.as_bytes().to_vec())
520        .bind(current_user.as_bytes().to_vec())
521        .fetch_all(&state.db)
522        .await
523        .map_err(|err| {
524            tracing::error!("list logos (group) failed: {err}");
525            Error::Database
526        })?,
527    };
528    let mut out = Vec::with_capacity(rows.len());
529    for row in rows {
530        let logo_id = uuid_from_bytes(&row.logo_id).ok_or_else(|| {
531            tracing::error!("bad logo uuid");
532            Error::Database
533        })?;
534        let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
535        let uploaded_by = row
536            .uploaded_by
537            .as_deref()
538            .map(uuid_from_bytes)
539            .map(|opt| {
540                opt.ok_or_else(|| {
541                    tracing::error!("bad uploaded_by uuid in saved_logos");
542                    Error::Database
543                })
544            })
545            .transpose()?;
546        out.push(LogoView {
547            logo_id,
548            destination: row_dest,
549            uploaded_by,
550            uploaded_by_username: row.uploader_username,
551            uploaded_by_legacy_name: row.uploader_legacy,
552            name: row.name,
553            content_type: row.content_type,
554            width: u32::try_from(row.width).unwrap_or(0),
555            height: u32::try_from(row.height).unwrap_or(0),
556            byte_size: u64::try_from(row.byte_size).unwrap_or(0),
557            created_at: row.created_at,
558        });
559    }
560    Ok(out)
561}
562
563/// Build a [`LogoView`] from the gathered pieces.
564#[expect(
565    clippy::too_many_arguments,
566    reason = "every argument is a distinct view field; bundling them would add indirection without removing parameters"
567)]
568fn build_view(
569    logo_id: Uuid,
570    destination: Destination,
571    uploaded_by: Option<Uuid>,
572    uploaded_by_username: Option<&str>,
573    uploaded_by_legacy_name: Option<&str>,
574    name: &str,
575    content_type: &str,
576    width: u32,
577    height: u32,
578    byte_size: usize,
579    created_at: DateTime<Utc>,
580) -> LogoView {
581    LogoView {
582        logo_id,
583        destination,
584        uploaded_by,
585        uploaded_by_username: uploaded_by_username.map(str::to_owned),
586        uploaded_by_legacy_name: uploaded_by_legacy_name.map(str::to_owned),
587        name: name.to_owned(),
588        content_type: content_type.to_owned(),
589        width,
590        height,
591        byte_size: u64::try_from(byte_size).unwrap_or(u64::MAX),
592        created_at,
593    }
594}
595
596/// Look up a user's display fields for view-building. Mirrors the helper in
597/// [`crate::routes::notecards`].
598async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
599    let row: Option<(String, String)> =
600        sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
601            .bind(user_id.as_bytes().to_vec())
602            .fetch_optional(&state.db)
603            .await
604            .map_err(|err| {
605                tracing::error!("user name lookup failed: {err}");
606                Error::Database
607            })?;
608    row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
609}
610
611#[cfg(test)]
612mod tests {
613    use pretty_assertions::assert_eq;
614
615    use super::content_type_for_format;
616    use crate::error::Error;
617
618    #[test]
619    fn accepted_formats_map_to_mime_types() {
620        assert_eq!(
621            content_type_for_format(image::ImageFormat::Png).ok(),
622            Some("image/png")
623        );
624        assert_eq!(
625            content_type_for_format(image::ImageFormat::Jpeg).ok(),
626            Some("image/jpeg")
627        );
628        assert_eq!(
629            content_type_for_format(image::ImageFormat::WebP).ok(),
630            Some("image/webp")
631        );
632    }
633
634    #[test]
635    fn other_formats_are_rejected() {
636        assert!(matches!(
637            content_type_for_format(image::ImageFormat::Gif),
638            Err(Error::BadRequest(_))
639        ));
640        assert!(matches!(
641            content_type_for_format(image::ImageFormat::Bmp),
642            Err(Error::BadRequest(_))
643        ));
644    }
645}