1use 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
27const MAX_LOGO_BYTES: usize = 5 * 1024 * 1024;
29
30const MAX_LOGO_DIMENSION: u32 = 2048;
33
34#[derive(Debug, Deserialize)]
36pub struct ScopeQuery {
37 pub scope: String,
39}
40
41#[derive(Debug, Serialize)]
43pub struct ListLogosResponse {
44 pub logos: Vec<LogoView>,
46}
47
48#[derive(Debug, Serialize)]
50pub struct LogoResponse {
51 pub logo: LogoView,
54}
55
56pub 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
73pub 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 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
144pub 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
180pub 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#[derive(Debug, Deserialize)]
201pub struct RenameRequest {
202 pub name: String,
204}
205
206pub 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
254pub 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 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
297struct CreateForm {
299 destination: Destination,
301 name: String,
303 content_type: &'static str,
305 width: u32,
307 height: u32,
309 bytes: bytes::Bytes,
311}
312
313fn 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
326async 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 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
393struct InsertLogo<'a> {
395 logo_id: Uuid,
397 destination: Destination,
399 uploaded_by: Uuid,
401 name: &'a str,
403 content_type: &'a str,
405 image_filename: &'a str,
407 width: u32,
409 height: u32,
411 byte_size: usize,
413 created_at: DateTime<Utc>,
415}
416
417async 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#[derive(sqlx::FromRow)]
456struct LogoListRow {
457 logo_id: Vec<u8>,
459 owner_user_id: Option<Vec<u8>>,
461 owner_group_id: Option<Vec<u8>>,
463 uploaded_by: Option<Vec<u8>>,
465 uploader_username: Option<String>,
467 uploader_legacy: Option<String>,
469 name: String,
471 content_type: String,
473 width: i64,
475 height: i64,
477 byte_size: i64,
479 created_at: DateTime<Utc>,
481}
482
483async 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 "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#[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
596async 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}