1use std::sync::atomic::Ordering;
10
11use axum::Json;
12use axum::extract::{Path, Query, State};
13use axum::http::StatusCode as ReqwestStatusCode;
14use axum::http::header;
15use axum::response::{IntoResponse as _, Response};
16use chrono::DateTime;
17use chrono::Utc;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::auth::{CurrentUser, uuid_from_bytes};
22use crate::error::Error;
23use crate::jobs::Metadata;
24use crate::library::{self, Destination, RenderView};
25use crate::routes::render::SavedRenderSettings;
26use crate::state::AppState;
27use crate::storage;
28
29#[derive(Debug, Deserialize)]
31pub struct ListQuery {
32 pub scope: String,
34 pub status: Option<String>,
36}
37
38#[derive(Debug, Serialize)]
40pub struct ListRendersResponse {
41 pub renders: Vec<RenderView>,
43}
44
45#[derive(Debug, Serialize)]
47pub struct RenderResponse {
48 pub render: RenderView,
52}
53
54pub async fn list(
63 user: CurrentUser,
64 State(state): State<AppState>,
65 Query(query): Query<ListQuery>,
66) -> Result<Json<ListRendersResponse>, Error> {
67 let destination = Destination::parse(&query.scope)?;
68 library::assert_can_view(&state.db, user.user_id, destination).await?;
69 let status = query
73 .status
74 .as_ref()
75 .map(|s| s.trim().to_owned())
76 .filter(|s| !s.is_empty());
77 let renders = fetch_renders_for(&state, user.user_id, destination, status.as_deref()).await?;
78 Ok(Json(ListRendersResponse { renders }))
79}
80
81pub async fn get(
87 user: CurrentUser,
88 State(state): State<AppState>,
89 Path(render_id): Path<Uuid>,
90) -> Result<Json<RenderResponse>, Error> {
91 let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
92 let destination =
93 library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
94 let creator_names = match row.created_by {
95 Some(id) => lookup_user_names(&state, id).await.ok(),
96 None => None,
97 };
98 let (creator_username, creator_legacy) = match creator_names {
99 Some((u, l)) => (Some(u), Some(l)),
100 None => (None, None),
101 };
102 let notecard_name = match row.notecard_id {
103 Some(id) => lookup_notecard_name(&state, id).await?,
104 None => None,
105 };
106 let glw_data_name = match row.glw_data_id {
107 Some(id) => lookup_glw_data_name(&state, id).await?,
108 None => None,
109 };
110 let view = RenderView {
111 render_id,
112 destination,
113 created_by: row.created_by,
114 created_by_username: creator_username,
115 created_by_legacy_name: creator_legacy,
116 notecard_id: row.notecard_id,
117 notecard_name,
118 kind: row.kind,
119 status: row.status,
120 error_message: row.error_message,
121 created_at: row.created_at,
122 finished_at: row.finished_at,
123 has_without_route: row.image_without_route_filename.is_some(),
124 content_type: row.content_type,
125 lower_left_x: row.lower_left_x,
126 lower_left_y: row.lower_left_y,
127 upper_right_x: row.upper_right_x,
128 upper_right_y: row.upper_right_y,
129 glw_data_id: row.glw_data_id,
130 glw_data_name,
131 };
132 Ok(Json(RenderResponse { render: view }))
133}
134
135pub async fn delete(
142 user: CurrentUser,
143 State(state): State<AppState>,
144 Path(render_id): Path<Uuid>,
145) -> Result<Response, Error> {
146 let row = library::assert_can_delete_render(&state.db, user.user_id, render_id).await?;
147 sqlx::query("DELETE FROM saved_renders WHERE render_id = ?1")
148 .bind(render_id.as_bytes().to_vec())
149 .execute(&state.db)
150 .await
151 .map_err(|err| {
152 tracing::error!("render delete failed: {err}");
153 Error::Database
154 })?;
155 for filename in [row.image_filename, row.image_without_route_filename]
158 .into_iter()
159 .flatten()
160 {
161 if let Err(err) = storage::try_delete_render_file(&state.config.storage_dir, &filename) {
162 tracing::warn!("inline unlink of {filename} failed: {err}");
163 state.library_cleanup_dirty.store(true, Ordering::Release);
164 }
165 }
166 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
167}
168
169pub async fn image(
176 user: CurrentUser,
177 State(state): State<AppState>,
178 Path(render_id): Path<Uuid>,
179) -> Result<Response, Error> {
180 serve_image(user, state, render_id, false, false).await
181}
182
183pub async fn image_without_route(
190 user: CurrentUser,
191 State(state): State<AppState>,
192 Path(render_id): Path<Uuid>,
193) -> Result<Response, Error> {
194 serve_image(user, state, render_id, true, false).await
195}
196
197pub async fn download(
205 user: CurrentUser,
206 State(state): State<AppState>,
207 Path(render_id): Path<Uuid>,
208) -> Result<Response, Error> {
209 serve_image(user, state, render_id, false, true).await
210}
211
212pub async fn download_without_route(
219 user: CurrentUser,
220 State(state): State<AppState>,
221 Path(render_id): Path<Uuid>,
222) -> Result<Response, Error> {
223 serve_image(user, state, render_id, true, true).await
224}
225
226pub async fn metadata(
236 user: CurrentUser,
237 State(state): State<AppState>,
238 Path(render_id): Path<Uuid>,
239) -> Result<Response, Error> {
240 let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
241 let raw = row
242 .metadata_json
243 .ok_or_else(|| Error::NotFound(format!("render {render_id} has no metadata yet")))?;
244 let parsed: Metadata = serde_json::from_str(&raw)?;
245 Ok(Json(parsed).into_response())
246}
247
248pub async fn settings(
258 user: CurrentUser,
259 State(state): State<AppState>,
260 Path(render_id): Path<Uuid>,
261) -> Result<Response, Error> {
262 let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
263 let parsed: SavedRenderSettings = serde_json::from_str(&row.settings_json)?;
264 Ok(Json(parsed).into_response())
265}
266
267async fn serve_image(
269 user: CurrentUser,
270 state: AppState,
271 render_id: Uuid,
272 want_without_route: bool,
273 attachment: bool,
274) -> Result<Response, Error> {
275 let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
276 if row.status != "done" {
277 return Err(Error::BadRequest(format!(
278 "render is not finished (status={})",
279 row.status
280 )));
281 }
282 let download_stem = if attachment {
285 Some(render_download_stem(&state, render_id, &row).await)
286 } else {
287 None
288 };
289 let filename = if want_without_route {
290 row.image_without_route_filename.ok_or_else(|| {
291 Error::NotFound(format!("render {render_id} has no without-route variant"))
292 })?
293 } else {
294 row.image_filename
295 .ok_or_else(|| Error::NotFound(format!("render {render_id} has no image")))?
296 };
297 let content_type = row
298 .content_type
299 .unwrap_or_else(|| "application/octet-stream".to_owned());
300 let bytes = storage::read_render_file(&state.config.storage_dir, &filename).await?;
301 let mut headers = axum::http::HeaderMap::new();
302 if let Ok(v) = axum::http::HeaderValue::from_str(&content_type) {
303 drop(headers.insert(header::CONTENT_TYPE, v));
304 }
305 if let Some(stem) = download_stem {
306 let ext = storage::ext_for_content_type(&content_type);
307 let suffix = if want_without_route { "-no-route" } else { "" };
308 let disposition = format!("attachment; filename=\"{stem}{suffix}.{ext}\"");
309 if let Ok(v) = axum::http::HeaderValue::from_str(&disposition) {
310 drop(headers.insert(header::CONTENT_DISPOSITION, v));
311 }
312 }
313 Ok((headers, bytes).into_response())
314}
315
316async fn render_download_stem(
323 state: &AppState,
324 render_id: Uuid,
325 row: &library::RenderRow,
326) -> String {
327 if let Some(id) = row.notecard_id
328 && let Ok(Some(name)) = lookup_notecard_name(state, id).await
329 && let Some(stem) = crate::routes::notecards::sanitise_for_filename(&name)
330 {
331 return stem;
332 }
333 if let (Some(ll_x), Some(ll_y), Some(ur_x), Some(ur_y)) = (
334 row.lower_left_x,
335 row.lower_left_y,
336 row.upper_right_x,
337 row.upper_right_y,
338 ) {
339 return format!("grid-{ll_x}-{ll_y}-{ur_x}-{ur_y}");
340 }
341 format!("render-{render_id}")
342}
343
344async fn fetch_renders_for(
347 state: &AppState,
348 current_user: Uuid,
349 destination: Destination,
350 status: Option<&str>,
351) -> Result<Vec<RenderView>, Error> {
352 let rows: Vec<RenderListRow> = match (destination, status) {
353 (Destination::Personal, None) => {
354 sqlx::query_as(LIST_PERSONAL_SQL)
355 .bind(current_user.as_bytes().to_vec())
356 .fetch_all(&state.db)
357 .await
358 }
359 (Destination::Personal, Some(s)) => {
360 sqlx::query_as(LIST_PERSONAL_STATUS_SQL)
361 .bind(current_user.as_bytes().to_vec())
362 .bind(s)
363 .fetch_all(&state.db)
364 .await
365 }
366 (Destination::Group { group_id }, None) => {
367 sqlx::query_as(LIST_GROUP_SQL)
368 .bind(group_id.as_bytes().to_vec())
369 .bind(current_user.as_bytes().to_vec())
370 .fetch_all(&state.db)
371 .await
372 }
373 (Destination::Group { group_id }, Some(s)) => {
374 sqlx::query_as(LIST_GROUP_STATUS_SQL)
375 .bind(group_id.as_bytes().to_vec())
376 .bind(current_user.as_bytes().to_vec())
377 .bind(s)
378 .fetch_all(&state.db)
379 .await
380 }
381 }
382 .map_err(|err| {
383 tracing::error!("list renders failed: {err}");
384 Error::Database
385 })?;
386 let mut out = Vec::with_capacity(rows.len());
387 for row in rows {
388 out.push(row.into_view()?);
389 }
390 Ok(out)
391}
392
393#[derive(sqlx::FromRow)]
397struct RenderListRow {
398 render_id: Vec<u8>,
400 owner_user_id: Option<Vec<u8>>,
402 owner_group_id: Option<Vec<u8>>,
404 created_by: Option<Vec<u8>>,
407 creator_username: Option<String>,
409 creator_legacy_name: Option<String>,
411 notecard_id: Option<Vec<u8>>,
413 notecard_name: Option<String>,
415 kind: String,
417 status: String,
419 error_message: Option<String>,
421 image_without_route_filename: Option<String>,
423 content_type: Option<String>,
425 created_at: DateTime<Utc>,
427 finished_at: Option<DateTime<Utc>>,
429 lower_left_x: Option<i64>,
431 lower_left_y: Option<i64>,
433 upper_right_x: Option<i64>,
435 upper_right_y: Option<i64>,
437 glw_data_id: Option<Vec<u8>>,
439 glw_data_name: Option<String>,
441}
442
443trait IntoView {
447 fn into_view(self) -> Result<RenderView, Error>;
453}
454
455impl IntoView for RenderListRow {
456 fn into_view(self) -> Result<RenderView, Error> {
457 let render_id = uuid_from_bytes(&self.render_id).ok_or_else(|| {
458 tracing::error!("bad render uuid");
459 Error::Database
460 })?;
461 let destination =
462 library::destination_from_columns(self.owner_user_id, self.owner_group_id)?;
463 let created_by = self
464 .created_by
465 .as_deref()
466 .map(uuid_from_bytes)
467 .map(|opt| {
468 opt.ok_or_else(|| {
469 tracing::error!("bad created_by uuid");
470 Error::Database
471 })
472 })
473 .transpose()?;
474 let notecard_id = self
475 .notecard_id
476 .as_deref()
477 .map(uuid_from_bytes)
478 .map(|opt| {
479 opt.ok_or_else(|| {
480 tracing::error!("bad notecard uuid");
481 Error::Database
482 })
483 })
484 .transpose()?;
485 let glw_data_id = self
486 .glw_data_id
487 .as_deref()
488 .map(uuid_from_bytes)
489 .map(|opt| {
490 opt.ok_or_else(|| {
491 tracing::error!("bad glw_data uuid");
492 Error::Database
493 })
494 })
495 .transpose()?;
496 Ok(RenderView {
497 render_id,
498 destination,
499 created_by,
500 created_by_username: self.creator_username,
501 created_by_legacy_name: self.creator_legacy_name,
502 notecard_id,
503 notecard_name: self.notecard_name,
504 kind: self.kind,
505 status: self.status,
506 error_message: self.error_message,
507 created_at: self.created_at,
508 finished_at: self.finished_at,
509 has_without_route: self.image_without_route_filename.is_some(),
510 content_type: self.content_type,
511 lower_left_x: self.lower_left_x.and_then(|v| u16::try_from(v).ok()),
512 lower_left_y: self.lower_left_y.and_then(|v| u16::try_from(v).ok()),
513 upper_right_x: self.upper_right_x.and_then(|v| u16::try_from(v).ok()),
514 upper_right_y: self.upper_right_y.and_then(|v| u16::try_from(v).ok()),
515 glw_data_id,
516 glw_data_name: self.glw_data_name,
517 })
518 }
519}
520
521async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
523 let row: Option<(String, String)> =
524 sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
525 .bind(user_id.as_bytes().to_vec())
526 .fetch_optional(&state.db)
527 .await
528 .map_err(|err| {
529 tracing::error!("user name lookup failed: {err}");
530 Error::Database
531 })?;
532 row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
533}
534
535async fn lookup_notecard_name(
539 state: &AppState,
540 notecard_id: Uuid,
541) -> Result<Option<String>, Error> {
542 let row: Option<(String,)> =
543 sqlx::query_as("SELECT name FROM saved_notecards WHERE notecard_id = ?1")
544 .bind(notecard_id.as_bytes().to_vec())
545 .fetch_optional(&state.db)
546 .await
547 .map_err(|err| {
548 tracing::error!("notecard name lookup failed: {err}");
549 Error::Database
550 })?;
551 Ok(row.map(|(name,)| name))
552}
553
554async fn lookup_glw_data_name(
558 state: &AppState,
559 glw_data_id: Uuid,
560) -> Result<Option<String>, Error> {
561 let row: Option<(String,)> =
562 sqlx::query_as("SELECT name FROM saved_glw_data WHERE glw_data_id = ?1")
563 .bind(glw_data_id.as_bytes().to_vec())
564 .fetch_optional(&state.db)
565 .await
566 .map_err(|err| {
567 tracing::error!("glw data name lookup failed: {err}");
568 Error::Database
569 })?;
570 Ok(row.map(|(name,)| name))
571}
572
573const LIST_PERSONAL_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
575 r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
576 r.error_message, r.image_without_route_filename, r.content_type, \
577 r.created_at, r.finished_at, \
578 r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
579 r.glw_data_id, g.name AS glw_data_name \
580 FROM saved_renders AS r \
581 LEFT JOIN users AS u ON u.user_id = r.created_by \
582 LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
583 LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
584 WHERE r.owner_user_id = ?1 \
585 ORDER BY r.created_at DESC";
586
587const LIST_PERSONAL_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
589 r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
590 r.error_message, r.image_without_route_filename, r.content_type, \
591 r.created_at, r.finished_at, \
592 r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
593 r.glw_data_id, g.name AS glw_data_name \
594 FROM saved_renders AS r \
595 LEFT JOIN users AS u ON u.user_id = r.created_by \
596 LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
597 LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
598 WHERE r.owner_user_id = ?1 AND r.status = ?2 \
599 ORDER BY r.created_at DESC";
600
601const LIST_GROUP_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
606 r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
607 r.error_message, r.image_without_route_filename, r.content_type, \
608 r.created_at, r.finished_at, \
609 r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
610 r.glw_data_id, g.name AS glw_data_name \
611 FROM saved_renders AS r \
612 LEFT JOIN users AS u ON u.user_id = r.created_by \
613 JOIN group_memberships AS gm \
614 ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
615 LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
616 LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
617 WHERE r.owner_group_id = ?1 \
618 AND (gm.role = 'owner' OR r.status = 'done') \
619 ORDER BY r.created_at DESC";
620
621const LIST_GROUP_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
624 r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
625 r.error_message, r.image_without_route_filename, r.content_type, \
626 r.created_at, r.finished_at, \
627 r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
628 r.glw_data_id, g.name AS glw_data_name \
629 FROM saved_renders AS r \
630 LEFT JOIN users AS u ON u.user_id = r.created_by \
631 JOIN group_memberships AS gm \
632 ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
633 LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
634 LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
635 WHERE r.owner_group_id = ?1 AND r.status = ?3 \
636 AND (gm.role = 'owner' OR r.status = 'done') \
637 ORDER BY r.created_at DESC";