use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{
Media, MediaCollection, MediaError, MediaManager, MediaStatus, MediaTag, UploadIntent,
};
#[allow(dead_code)]
const DEFAULT_PRESIGN_TTL_SECS: u64 = 3600;
pub fn media_router(manager: MediaManager) -> Router {
let state = Arc::new(manager);
Router::new()
.route("/uploads/begin", post(begin_upload_handler))
.route("/uploads/{id}/finalize", post(finalize_upload_handler))
.route(
"/media/{id}",
get(get_media_handler).delete(delete_media_handler),
)
.route("/media/{id}/move", post(move_media_handler))
.route("/media/{id}/tags", post(set_tags_handler))
.route("/media/{id}/tags/{slug}", delete(untag_handler))
.route(
"/collections",
post(create_collection_handler).get(list_collections_handler),
)
.route(
"/collections/{id}",
get(get_collection_handler).delete(delete_collection_handler),
)
.route(
"/collections/{id}/contents",
get(collection_contents_handler),
)
.route("/tags", post(create_tag_handler).get(list_tags_handler))
.route("/tags/popular", get(popular_tags_handler))
.route("/tags/{slug}/media", get(media_with_tag_handler))
.with_state(state)
}
#[derive(Debug, Deserialize)]
struct BeginUploadBody {
disk: String,
#[serde(default)]
key_prefix: String,
mime: String,
original_filename: String,
size_bytes: i64,
#[serde(default)]
uploaded_by_id: Option<i64>,
#[serde(default)]
collection_id: Option<i64>,
#[serde(default = "default_ttl_secs")]
ttl_secs: u64,
}
fn default_ttl_secs() -> u64 {
300
}
#[derive(Debug, Serialize)]
struct UploadTicketBody {
media_id: i64,
upload_url: String,
expires_at: chrono::DateTime<chrono::Utc>,
disk: String,
storage_key: String,
}
#[derive(Debug, Serialize)]
struct MediaResponse {
id: i64,
disk: String,
storage_key: String,
mime: String,
size_bytes: i64,
original_filename: String,
status: String,
uploaded_at: chrono::DateTime<chrono::Utc>,
uploaded_by_id: Option<i64>,
derived_from_id: Option<i64>,
collection_id: Option<i64>,
metadata: Value,
url: Option<String>,
presigned_url: Option<String>,
tags: Vec<String>,
}
impl MediaResponse {
async fn from_row(manager: &MediaManager, m: Media) -> Result<Self, MediaError> {
let id = match m.id {
crate::sql::Auto::Set(v) => v,
_ => 0,
};
let url = manager.url(&m);
let presigned = manager
.presigned_get(&m, Duration::from_secs(DEFAULT_PRESIGN_TTL_SECS))
.await;
let tags = manager
.tags_for(id)
.await?
.into_iter()
.map(|t| t.slug)
.collect();
Ok(Self {
id,
disk: m.disk,
storage_key: m.storage_key,
mime: m.mime,
size_bytes: m.size_bytes,
original_filename: m.original_filename,
status: m.status,
uploaded_at: m.uploaded_at,
uploaded_by_id: m.uploaded_by_id,
derived_from_id: m.derived_from_id,
collection_id: m.collection_id,
metadata: m.metadata,
url,
presigned_url: presigned,
tags,
})
}
}
#[derive(Debug, Deserialize)]
struct MoveBody {
collection_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct SetTagsBody {
slugs: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct CreateCollectionBody {
name: String,
slug: String,
#[serde(default)]
parent_id: Option<i64>,
#[serde(default)]
description: String,
}
#[derive(Debug, Serialize)]
struct CollectionResponse {
id: i64,
name: String,
slug: String,
parent_id: Option<i64>,
description: String,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<MediaCollection> for CollectionResponse {
fn from(c: MediaCollection) -> Self {
let id = match c.id {
crate::sql::Auto::Set(v) => v,
_ => 0,
};
Self {
id,
name: c.name,
slug: c.slug,
parent_id: c.parent_id,
description: c.description,
created_at: c.created_at,
}
}
}
#[derive(Debug, Deserialize)]
struct CreateTagBody {
slug: String,
}
#[derive(Debug, Serialize)]
struct TagResponse {
id: i64,
name: String,
slug: String,
}
impl From<MediaTag> for TagResponse {
fn from(t: MediaTag) -> Self {
let id = match t.id {
crate::sql::Auto::Set(v) => v,
_ => 0,
};
Self {
id,
name: t.name,
slug: t.slug,
}
}
}
#[derive(Debug, Deserialize)]
struct PopularQuery {
#[serde(default = "default_limit")]
limit: i64,
}
#[derive(Debug, Deserialize)]
struct ListWithTagQuery {
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Deserialize)]
struct ContentsQuery {
#[serde(default)]
recursive: bool,
}
impl IntoResponse for MediaError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
MediaError::UnknownDisk(_) => (StatusCode::BAD_REQUEST, self.to_string()),
MediaError::Storage(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
MediaError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
MediaError::Other(m) if m.contains("not found") => {
(StatusCode::NOT_FOUND, self.to_string())
}
MediaError::Other(_) => (StatusCode::BAD_REQUEST, self.to_string()),
};
let body = serde_json::json!({"error": msg});
(status, Json(body)).into_response()
}
}
async fn begin_upload_handler(
State(manager): State<Arc<MediaManager>>,
Json(body): Json<BeginUploadBody>,
) -> Result<Json<UploadTicketBody>, MediaError> {
let ticket = manager
.begin_upload(UploadIntent {
disk: body.disk,
key_prefix: body.key_prefix,
mime: body.mime,
original_filename: body.original_filename,
size_bytes: body.size_bytes,
uploaded_by_id: body.uploaded_by_id,
collection_id: body.collection_id,
ttl: Duration::from_secs(body.ttl_secs.clamp(60, 3600)),
})
.await?;
Ok(Json(UploadTicketBody {
media_id: ticket.media_id,
upload_url: ticket.upload_url,
expires_at: ticket.expires_at,
disk: ticket.disk,
storage_key: ticket.storage_key,
}))
}
async fn finalize_upload_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
) -> Result<Json<MediaResponse>, MediaError> {
let m = manager.finalize_upload(id).await?;
let resp = MediaResponse::from_row(&manager, m).await?;
Ok(Json(resp))
}
async fn get_media_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
) -> Result<Json<MediaResponse>, MediaError> {
let m = manager
.get(id)
.await?
.ok_or_else(|| MediaError::Other(format!("media {id} not found")))?;
let resp = MediaResponse::from_row(&manager, m).await?;
Ok(Json(resp))
}
async fn delete_media_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
) -> Result<StatusCode, MediaError> {
let m = manager
.get(id)
.await?
.ok_or_else(|| MediaError::Other(format!("media {id} not found")))?;
manager.delete(&m).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn move_media_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
Json(body): Json<MoveBody>,
) -> Result<StatusCode, MediaError> {
manager.move_to_collection(id, body.collection_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn set_tags_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
Json(body): Json<SetTagsBody>,
) -> Result<StatusCode, MediaError> {
let slugs: Vec<&str> = body.slugs.iter().map(String::as_str).collect();
manager.set_tags(id, &slugs).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn untag_handler(
State(manager): State<Arc<MediaManager>>,
Path((id, slug)): Path<(i64, String)>,
) -> Result<StatusCode, MediaError> {
manager.untag(id, &slug).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn create_collection_handler(
State(manager): State<Arc<MediaManager>>,
Json(body): Json<CreateCollectionBody>,
) -> Result<(StatusCode, Json<CollectionResponse>), MediaError> {
let c = manager
.create_collection(body.name, body.slug, body.parent_id, body.description)
.await?;
Ok((StatusCode::CREATED, Json(c.into())))
}
async fn list_collections_handler(
State(manager): State<Arc<MediaManager>>,
) -> Result<Json<Vec<CollectionResponse>>, MediaError> {
let cs = manager.list_collections().await?;
Ok(Json(cs.into_iter().map(Into::into).collect()))
}
async fn get_collection_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
) -> Result<Json<CollectionResponse>, MediaError> {
let c = manager
.get_collection(id)
.await?
.ok_or_else(|| MediaError::Other(format!("collection {id} not found")))?;
Ok(Json(c.into()))
}
async fn delete_collection_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
) -> Result<StatusCode, MediaError> {
manager.delete_collection(id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn collection_contents_handler(
State(manager): State<Arc<MediaManager>>,
Path(id): Path<i64>,
Query(q): Query<ContentsQuery>,
) -> Result<Json<Vec<MediaResponse>>, MediaError> {
let media = manager.list_in_collection(id, q.recursive).await?;
let mut out = Vec::with_capacity(media.len());
for m in media {
out.push(MediaResponse::from_row(&manager, m).await?);
}
Ok(Json(out))
}
async fn create_tag_handler(
State(manager): State<Arc<MediaManager>>,
Json(body): Json<CreateTagBody>,
) -> Result<(StatusCode, Json<TagResponse>), MediaError> {
let t = manager.ensure_tag(&body.slug).await?;
Ok((StatusCode::CREATED, Json(t.into())))
}
async fn list_tags_handler(
State(manager): State<Arc<MediaManager>>,
) -> Result<Json<Vec<TagResponse>>, MediaError> {
let pairs = manager.popular_tags(1000).await?;
Ok(Json(pairs.into_iter().map(|(t, _)| t.into()).collect()))
}
async fn popular_tags_handler(
State(manager): State<Arc<MediaManager>>,
Query(q): Query<PopularQuery>,
) -> Result<Json<Vec<PopularTagEntry>>, MediaError> {
let pairs = manager.popular_tags(q.limit).await?;
let resp = pairs
.into_iter()
.map(|(t, count)| PopularTagEntry {
tag: t.into(),
use_count: count,
})
.collect();
Ok(Json(resp))
}
#[derive(Debug, Serialize)]
struct PopularTagEntry {
#[serde(flatten)]
tag: TagResponse,
use_count: i64,
}
async fn media_with_tag_handler(
State(manager): State<Arc<MediaManager>>,
Path(slug): Path<String>,
Query(q): Query<ListWithTagQuery>,
) -> Result<Json<Vec<MediaResponse>>, MediaError> {
let rows = manager.list_with_tag(&slug, q.limit, q.offset).await?;
let mut out = Vec::with_capacity(rows.len());
for m in rows {
out.push(MediaResponse::from_row(&manager, m).await?);
}
Ok(Json(out))
}
#[allow(dead_code)]
fn _media_status_marker(_: MediaStatus) {}