use std::sync::atomic::Ordering;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::http::header;
use axum::response::{IntoResponse as _, Response};
use chrono::DateTime;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{CurrentUser, uuid_from_bytes};
use crate::error::Error;
use crate::jobs::Metadata;
use crate::library::{self, Destination, RenderView};
use crate::routes::render::SavedRenderSettings;
use crate::state::AppState;
use crate::storage;
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub scope: String,
pub status: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListRendersResponse {
pub renders: Vec<RenderView>,
}
#[derive(Debug, Serialize)]
pub struct RenderResponse {
pub render: RenderView,
}
pub async fn list(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<ListRendersResponse>, Error> {
let destination = Destination::parse(&query.scope)?;
library::assert_can_view(&state.db, user.user_id, destination).await?;
let status = query
.status
.as_ref()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty());
let renders = fetch_renders_for(&state, user.user_id, destination, status.as_deref()).await?;
Ok(Json(ListRendersResponse { renders }))
}
pub async fn get(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Json<RenderResponse>, Error> {
let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let creator_names = match row.created_by {
Some(id) => lookup_user_names(&state, id).await.ok(),
None => None,
};
let (creator_username, creator_legacy) = match creator_names {
Some((u, l)) => (Some(u), Some(l)),
None => (None, None),
};
let notecard_name = match row.notecard_id {
Some(id) => lookup_notecard_name(&state, id).await?,
None => None,
};
let glw_data_name = match row.glw_data_id {
Some(id) => lookup_glw_data_name(&state, id).await?,
None => None,
};
let view = RenderView {
render_id,
destination,
created_by: row.created_by,
created_by_username: creator_username,
created_by_legacy_name: creator_legacy,
notecard_id: row.notecard_id,
notecard_name,
kind: row.kind,
status: row.status,
error_message: row.error_message,
created_at: row.created_at,
finished_at: row.finished_at,
has_without_route: row.image_without_route_filename.is_some(),
content_type: row.content_type,
lower_left_x: row.lower_left_x,
lower_left_y: row.lower_left_y,
upper_right_x: row.upper_right_x,
upper_right_y: row.upper_right_y,
glw_data_id: row.glw_data_id,
glw_data_name,
};
Ok(Json(RenderResponse { render: view }))
}
pub async fn delete(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = library::assert_can_delete_render(&state.db, user.user_id, render_id).await?;
sqlx::query("DELETE FROM saved_renders WHERE render_id = ?1")
.bind(render_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("render delete failed: {err}");
Error::Database
})?;
for filename in [row.image_filename, row.image_without_route_filename]
.into_iter()
.flatten()
{
if let Err(err) = storage::try_delete_render_file(&state.config.storage_dir, &filename) {
tracing::warn!("inline unlink of {filename} failed: {err}");
state.library_cleanup_dirty.store(true, Ordering::Release);
}
}
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
pub async fn image(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
serve_image(user, state, render_id, false, false).await
}
pub async fn image_without_route(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
serve_image(user, state, render_id, true, false).await
}
pub async fn download(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
serve_image(user, state, render_id, false, true).await
}
pub async fn download_without_route(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
serve_image(user, state, render_id, true, true).await
}
pub async fn metadata(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
let raw = row
.metadata_json
.ok_or_else(|| Error::NotFound(format!("render {render_id} has no metadata yet")))?;
let parsed: Metadata = serde_json::from_str(&raw)?;
Ok(Json(parsed).into_response())
}
pub async fn settings(
user: CurrentUser,
State(state): State<AppState>,
Path(render_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
let parsed: SavedRenderSettings = serde_json::from_str(&row.settings_json)?;
Ok(Json(parsed).into_response())
}
async fn serve_image(
user: CurrentUser,
state: AppState,
render_id: Uuid,
want_without_route: bool,
attachment: bool,
) -> Result<Response, Error> {
let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
if row.status != "done" {
return Err(Error::BadRequest(format!(
"render is not finished (status={})",
row.status
)));
}
let download_stem = if attachment {
Some(render_download_stem(&state, render_id, &row).await)
} else {
None
};
let filename = if want_without_route {
row.image_without_route_filename.ok_or_else(|| {
Error::NotFound(format!("render {render_id} has no without-route variant"))
})?
} else {
row.image_filename
.ok_or_else(|| Error::NotFound(format!("render {render_id} has no image")))?
};
let content_type = row
.content_type
.unwrap_or_else(|| "application/octet-stream".to_owned());
let bytes = storage::read_render_file(&state.config.storage_dir, &filename).await?;
let mut headers = axum::http::HeaderMap::new();
if let Ok(v) = axum::http::HeaderValue::from_str(&content_type) {
drop(headers.insert(header::CONTENT_TYPE, v));
}
if let Some(stem) = download_stem {
let ext = storage::ext_for_content_type(&content_type);
let suffix = if want_without_route { "-no-route" } else { "" };
let disposition = format!("attachment; filename=\"{stem}{suffix}.{ext}\"");
if let Ok(v) = axum::http::HeaderValue::from_str(&disposition) {
drop(headers.insert(header::CONTENT_DISPOSITION, v));
}
}
Ok((headers, bytes).into_response())
}
async fn render_download_stem(
state: &AppState,
render_id: Uuid,
row: &library::RenderRow,
) -> String {
if let Some(id) = row.notecard_id
&& let Ok(Some(name)) = lookup_notecard_name(state, id).await
&& let Some(stem) = crate::routes::notecards::sanitise_for_filename(&name)
{
return stem;
}
if let (Some(ll_x), Some(ll_y), Some(ur_x), Some(ur_y)) = (
row.lower_left_x,
row.lower_left_y,
row.upper_right_x,
row.upper_right_y,
) {
return format!("grid-{ll_x}-{ll_y}-{ur_x}-{ur_y}");
}
format!("render-{render_id}")
}
async fn fetch_renders_for(
state: &AppState,
current_user: Uuid,
destination: Destination,
status: Option<&str>,
) -> Result<Vec<RenderView>, Error> {
let rows: Vec<RenderListRow> = match (destination, status) {
(Destination::Personal, None) => {
sqlx::query_as(LIST_PERSONAL_SQL)
.bind(current_user.as_bytes().to_vec())
.fetch_all(&state.db)
.await
}
(Destination::Personal, Some(s)) => {
sqlx::query_as(LIST_PERSONAL_STATUS_SQL)
.bind(current_user.as_bytes().to_vec())
.bind(s)
.fetch_all(&state.db)
.await
}
(Destination::Group { group_id }, None) => {
sqlx::query_as(LIST_GROUP_SQL)
.bind(group_id.as_bytes().to_vec())
.bind(current_user.as_bytes().to_vec())
.fetch_all(&state.db)
.await
}
(Destination::Group { group_id }, Some(s)) => {
sqlx::query_as(LIST_GROUP_STATUS_SQL)
.bind(group_id.as_bytes().to_vec())
.bind(current_user.as_bytes().to_vec())
.bind(s)
.fetch_all(&state.db)
.await
}
}
.map_err(|err| {
tracing::error!("list renders failed: {err}");
Error::Database
})?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
out.push(row.into_view()?);
}
Ok(out)
}
#[derive(sqlx::FromRow)]
struct RenderListRow {
render_id: Vec<u8>,
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
created_by: Option<Vec<u8>>,
creator_username: Option<String>,
creator_legacy_name: Option<String>,
notecard_id: Option<Vec<u8>>,
notecard_name: Option<String>,
kind: String,
status: String,
error_message: Option<String>,
image_without_route_filename: Option<String>,
content_type: Option<String>,
created_at: DateTime<Utc>,
finished_at: Option<DateTime<Utc>>,
lower_left_x: Option<i64>,
lower_left_y: Option<i64>,
upper_right_x: Option<i64>,
upper_right_y: Option<i64>,
glw_data_id: Option<Vec<u8>>,
glw_data_name: Option<String>,
}
trait IntoView {
fn into_view(self) -> Result<RenderView, Error>;
}
impl IntoView for RenderListRow {
fn into_view(self) -> Result<RenderView, Error> {
let render_id = uuid_from_bytes(&self.render_id).ok_or_else(|| {
tracing::error!("bad render uuid");
Error::Database
})?;
let destination =
library::destination_from_columns(self.owner_user_id, self.owner_group_id)?;
let created_by = self
.created_by
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid");
Error::Database
})
})
.transpose()?;
let notecard_id = self
.notecard_id
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad notecard uuid");
Error::Database
})
})
.transpose()?;
let glw_data_id = self
.glw_data_id
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad glw_data uuid");
Error::Database
})
})
.transpose()?;
Ok(RenderView {
render_id,
destination,
created_by,
created_by_username: self.creator_username,
created_by_legacy_name: self.creator_legacy_name,
notecard_id,
notecard_name: self.notecard_name,
kind: self.kind,
status: self.status,
error_message: self.error_message,
created_at: self.created_at,
finished_at: self.finished_at,
has_without_route: self.image_without_route_filename.is_some(),
content_type: self.content_type,
lower_left_x: self.lower_left_x.and_then(|v| u16::try_from(v).ok()),
lower_left_y: self.lower_left_y.and_then(|v| u16::try_from(v).ok()),
upper_right_x: self.upper_right_x.and_then(|v| u16::try_from(v).ok()),
upper_right_y: self.upper_right_y.and_then(|v| u16::try_from(v).ok()),
glw_data_id,
glw_data_name: self.glw_data_name,
})
}
}
async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
let row: Option<(String, String)> =
sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
.bind(user_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("user name lookup failed: {err}");
Error::Database
})?;
row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
}
async fn lookup_notecard_name(
state: &AppState,
notecard_id: Uuid,
) -> Result<Option<String>, Error> {
let row: Option<(String,)> =
sqlx::query_as("SELECT name FROM saved_notecards WHERE notecard_id = ?1")
.bind(notecard_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("notecard name lookup failed: {err}");
Error::Database
})?;
Ok(row.map(|(name,)| name))
}
async fn lookup_glw_data_name(
state: &AppState,
glw_data_id: Uuid,
) -> Result<Option<String>, Error> {
let row: Option<(String,)> =
sqlx::query_as("SELECT name FROM saved_glw_data WHERE glw_data_id = ?1")
.bind(glw_data_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("glw data name lookup failed: {err}");
Error::Database
})?;
Ok(row.map(|(name,)| name))
}
const LIST_PERSONAL_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
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, \
r.error_message, r.image_without_route_filename, r.content_type, \
r.created_at, r.finished_at, \
r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
r.glw_data_id, g.name AS glw_data_name \
FROM saved_renders AS r \
LEFT JOIN users AS u ON u.user_id = r.created_by \
LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
WHERE r.owner_user_id = ?1 \
ORDER BY r.created_at DESC";
const LIST_PERSONAL_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
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, \
r.error_message, r.image_without_route_filename, r.content_type, \
r.created_at, r.finished_at, \
r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
r.glw_data_id, g.name AS glw_data_name \
FROM saved_renders AS r \
LEFT JOIN users AS u ON u.user_id = r.created_by \
LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
WHERE r.owner_user_id = ?1 AND r.status = ?2 \
ORDER BY r.created_at DESC";
const LIST_GROUP_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
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, \
r.error_message, r.image_without_route_filename, r.content_type, \
r.created_at, r.finished_at, \
r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
r.glw_data_id, g.name AS glw_data_name \
FROM saved_renders AS r \
LEFT JOIN users AS u ON u.user_id = r.created_by \
JOIN group_memberships AS gm \
ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
WHERE r.owner_group_id = ?1 \
AND (gm.role = 'owner' OR r.status = 'done') \
ORDER BY r.created_at DESC";
const LIST_GROUP_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
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, \
r.error_message, r.image_without_route_filename, r.content_type, \
r.created_at, r.finished_at, \
r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
r.glw_data_id, g.name AS glw_data_name \
FROM saved_renders AS r \
LEFT JOIN users AS u ON u.user_id = r.created_by \
JOIN group_memberships AS gm \
ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
WHERE r.owner_group_id = ?1 AND r.status = ?3 \
AND (gm.role = 'owner' OR r.status = 'done') \
ORDER BY r.created_at DESC";