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 crate::api_errors::ApiError;
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;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaTarget {
Media(i64),
Collection(i64),
CollectionContents {
id: i64,
recursive: bool,
},
CollectionSubtree(i64),
Tag(String),
#[non_exhaustive]
NewUpload {
disk: String,
key_prefix: String,
collection_id: Option<i64>,
uploaded_by_id: Option<i64>,
},
#[non_exhaustive]
NewCollection {
parent_id: Option<i64>,
},
#[non_exhaustive]
MediaMove {
id: i64,
collection_id: Option<i64>,
},
#[non_exhaustive]
NewTag {},
Listing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaAction {
Read(MediaTarget),
Add(MediaTarget),
Change(MediaTarget),
Delete(MediaTarget),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaDecision {
Allow,
Unauthenticated,
Forbidden,
}
impl From<bool> for MediaDecision {
fn from(allowed: bool) -> Self {
if allowed {
Self::Allow
} else {
Self::Forbidden
}
}
}
#[async_trait::async_trait]
pub trait MediaAuthorizer: Send + Sync + 'static {
async fn authorize(
&self,
parts: &axum::http::request::Parts,
action: MediaAction,
) -> MediaDecision;
}
#[async_trait::async_trait]
impl<T: MediaAuthorizer + ?Sized> MediaAuthorizer for Arc<T> {
async fn authorize(
&self,
parts: &axum::http::request::Parts,
action: MediaAction,
) -> MediaDecision {
(**self).authorize(parts, action).await
}
}
struct DenyAll;
#[async_trait::async_trait]
impl MediaAuthorizer for DenyAll {
async fn authorize(&self, _: &axum::http::request::Parts, _: MediaAction) -> MediaDecision {
MediaDecision::Forbidden
}
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn required_codenames(action: &MediaAction) -> Option<&'static [&'static str]> {
use MediaAction as A;
use MediaTarget as T;
Some(match action {
A::Read(T::Media(_) | T::Tag(_)) => &["rustango_media.view"],
A::Read(T::Collection(_)) => &["rustango_media_collections.view"],
A::Read(T::CollectionContents { .. }) => {
&["rustango_media_collections.view", "rustango_media.view"]
}
#[allow(clippy::match_same_arms)]
A::Read(T::Listing) => &["rustango_media.view"],
A::Add(T::NewUpload {
collection_id: Some(_),
..
}) => &["rustango_media.add", "rustango_media_collections.view"],
A::Add(T::NewUpload { .. }) => &["rustango_media.add"],
A::Add(T::NewCollection { parent_id: Some(_) }) => &[
"rustango_media_collections.add",
"rustango_media_collections.view",
],
A::Add(T::NewCollection { .. }) => &["rustango_media_collections.add"],
A::Add(T::NewTag { .. }) => &["rustango_media_tags.add"],
A::Change(T::MediaMove {
collection_id: Some(_),
..
}) => &["rustango_media.change", "rustango_media_collections.view"],
A::Change(T::Media(_) | T::MediaMove { .. }) => &["rustango_media.change"],
A::Delete(T::Media(_)) => &["rustango_media.delete"],
A::Delete(T::CollectionSubtree(_)) => {
&["rustango_media_collections.delete", "rustango_media.change"]
}
_ => return None,
})
}
#[cfg(feature = "tenancy")]
pub struct MediaPerms {
pool: crate::sql::Pool,
allowed_disks: Option<Vec<String>>,
}
#[cfg(feature = "tenancy")]
impl MediaPerms {
#[must_use]
pub fn new(pool: crate::sql::Pool) -> Self {
Self {
pool,
allowed_disks: None,
}
}
#[must_use]
pub fn from_manager(manager: &MediaManager) -> Self {
Self::new(manager.pool().clone())
}
#[must_use]
pub fn allow_disks<I, S>(mut self, disks: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_disks = Some(disks.into_iter().map(Into::into).collect());
self
}
fn disk_allowed(&self, disk: &str) -> bool {
match &self.allowed_disks {
Some(allowed) => allowed.iter().any(|d| d == disk),
None => true,
}
}
}
#[cfg(feature = "tenancy")]
#[async_trait::async_trait]
impl MediaAuthorizer for MediaPerms {
async fn authorize(
&self,
parts: &axum::http::request::Parts,
action: MediaAction,
) -> MediaDecision {
let Some(auth) = parts
.extensions
.get::<crate::tenancy::middleware::AuthenticatedUser>()
else {
return MediaDecision::Unauthenticated;
};
if let MediaAction::Add(MediaTarget::NewUpload { disk, .. }) = &action {
if !self.disk_allowed(disk) {
tracing::debug!(
target: "rustango::media::auth",
disk = %disk,
user_id = auth.id,
superuser = auth.is_superuser,
"MediaPerms: upload ticket refused — disk not in the allow-list"
);
return MediaDecision::Forbidden;
}
}
if auth.is_superuser {
return MediaDecision::Allow;
}
if let MediaAction::Add(MediaTarget::NewUpload {
uploaded_by_id: Some(by),
..
}) = &action
{
if *by != auth.id {
return MediaDecision::Forbidden;
}
}
let Some(codenames) = required_codenames(&action) else {
tracing::debug!(
target: "rustango::media::auth",
?action,
"MediaPerms has no codename for this action — refusing"
);
return MediaDecision::Forbidden;
};
if codenames.is_empty() {
tracing::warn!(
target: "rustango::media::auth",
?action,
"MediaPerms: empty codename list — refusing rather than allowing"
);
return MediaDecision::Forbidden;
}
for cn in codenames {
match crate::tenancy::permissions::has_perm_pool(auth.id, cn, &self.pool).await {
Ok(true) => {}
Ok(false) => return MediaDecision::Forbidden,
Err(e) => {
tracing::warn!(
target: "rustango::media::auth",
codename = %cn,
user_id = auth.id,
error = %e,
"permission lookup failed — refusing"
);
return MediaDecision::Forbidden;
}
}
}
MediaDecision::Allow
}
}
#[derive(Debug, Default, Deserialize)]
struct UploadRequestDetail {
#[serde(default)]
disk: String,
#[serde(default)]
key_prefix: String,
#[serde(default)]
collection_id: Option<i64>,
#[serde(default)]
uploaded_by_id: Option<i64>,
}
const MAX_GATE_BODY_BYTES: usize = 16 * 1024;
const MAX_GATE_COLLECTION_BODY_BYTES: usize = 2 * 1024 * 1024;
fn gate_body_limit(method: &axum::http::Method, path: &str) -> Option<usize> {
if method != axum::http::Method::POST {
return None;
}
let seg: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(crate::url_codec::percent_decode_path)
.collect();
let s: Vec<&str> = seg.iter().map(String::as_str).collect();
match s.as_slice() {
["uploads", "begin"] | ["media", _, "move"] => Some(MAX_GATE_BODY_BYTES),
["collections"] => Some(MAX_GATE_COLLECTION_BODY_BYTES),
_ => None,
}
}
fn classify(
method: &axum::http::Method,
path: &str,
query: Option<&str>,
body: Option<&[u8]>,
) -> Option<MediaAction> {
use axum::http::Method;
fn parse<T: serde::de::DeserializeOwned>(body: Option<&[u8]>) -> Option<T> {
body.and_then(|b| serde_json::from_slice(b).ok())
}
let seg: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(crate::url_codec::percent_decode_path)
.collect();
let s: Vec<&str> = seg.iter().map(String::as_str).collect();
let id = |raw: &str| raw.parse::<i64>().ok();
let m = if method == Method::HEAD {
&Method::GET
} else {
method
};
let recursive = query.is_some_and(|q| {
q.split('&').any(|p| {
let key = p.split('=').next().unwrap_or(p);
crate::url_codec::url_decode(key) == "recursive"
})
});
match (m, s.as_slice()) {
(&Method::POST, ["uploads", "begin"]) => {
let d = parse::<UploadRequestDetail>(body).unwrap_or_default();
Some(MediaAction::Add(MediaTarget::NewUpload {
disk: d.disk,
key_prefix: d.key_prefix,
collection_id: d.collection_id,
uploaded_by_id: d.uploaded_by_id,
}))
}
(&Method::POST, ["uploads", raw, "finalize"]) => {
Some(MediaAction::Change(MediaTarget::Media(id(raw)?)))
}
(&Method::GET, ["media", raw]) => Some(MediaAction::Read(MediaTarget::Media(id(raw)?))),
(&Method::DELETE, ["media", raw]) => {
Some(MediaAction::Delete(MediaTarget::Media(id(raw)?)))
}
(&Method::POST, ["media", raw, "move"]) => {
Some(MediaAction::Change(MediaTarget::MediaMove {
id: id(raw)?,
collection_id: parse::<MoveBody>(body).and_then(|b| b.collection_id),
}))
}
(&Method::POST, ["media", raw, "tags"]) | (&Method::DELETE, ["media", raw, "tags", _]) => {
Some(MediaAction::Change(MediaTarget::Media(id(raw)?)))
}
(&Method::GET, ["collections"]) => Some(MediaAction::Read(MediaTarget::Listing)),
(&Method::POST, ["collections"]) => Some(MediaAction::Add(MediaTarget::NewCollection {
parent_id: parse::<CreateCollectionBody>(body).and_then(|b| b.parent_id),
})),
(&Method::GET, ["collections", raw]) => {
Some(MediaAction::Read(MediaTarget::Collection(id(raw)?)))
}
(&Method::GET, ["collections", raw, "contents"]) => {
Some(MediaAction::Read(MediaTarget::CollectionContents {
id: id(raw)?,
recursive,
}))
}
(&Method::DELETE, ["collections", raw]) => Some(MediaAction::Delete(
MediaTarget::CollectionSubtree(id(raw)?),
)),
(&Method::GET, ["tags"] | ["tags", "popular"]) => {
Some(MediaAction::Read(MediaTarget::Listing))
}
(&Method::POST, ["tags"]) => Some(MediaAction::Add(MediaTarget::NewTag {})),
(&Method::GET, ["tags", slug, "media"]) => {
Some(MediaAction::Read(MediaTarget::Tag((*slug).to_owned())))
}
_ => None,
}
}
pub fn media_router_with<A: MediaAuthorizer>(manager: MediaManager, authorizer: A) -> Router {
let auth = Arc::new(authorizer);
media_routes(manager)
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let auth = Arc::clone(&auth);
async move {
let (parts, body) = req.into_parts();
let (bytes, body) =
if let Some(limit) = gate_body_limit(&parts.method, parts.uri.path()) {
let Ok(bytes) = axum::body::to_bytes(body, limit).await else {
tracing::debug!(
target: "rustango::media::auth",
path = %parts.uri.path(),
"refused: request body could not be read within the \
gate's limit"
);
return ApiError::forbidden("request body too large to authorize")
.into_response();
};
(Some(bytes.clone()), axum::body::Body::from(bytes))
} else {
(None, body)
};
let Some(action) = classify(
&parts.method,
parts.uri.path(),
parts.uri.query(),
bytes.as_deref(),
) else {
tracing::debug!(
target: "rustango::media::auth",
method = %parts.method,
path = %parts.uri.path(),
"refused: no route in the media table matches"
);
return ApiError::forbidden("not a recognised media operation")
.into_response();
};
let decision = auth.authorize(&parts, action.clone()).await;
if decision != MediaDecision::Allow {
tracing::debug!(
target: "rustango::media::auth",
?action,
?decision,
method = %parts.method,
path = %parts.uri.path(),
"refused by the authorization policy"
);
return match decision {
MediaDecision::Unauthenticated => {
ApiError::unauthorized("authentication required")
}
_ => ApiError::forbidden("not authorized for this media operation"),
}
.into_response();
}
next.run(axum::extract::Request::from_parts(parts, body))
.await
}
},
))
.layer(axum::middleware::from_fn(no_store))
}
async fn no_store(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
let mut resp = next.run(req).await;
resp.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
resp.headers_mut().insert(
axum::http::header::VARY,
axum::http::HeaderValue::from_static("Cookie, Authorization"),
);
resp
}
#[deprecated(
since = "0.57.7",
note = "serves nothing — every route is 403, and it is removed in 0.59.0. Use `media_router_with(manager, authorizer)` and supply a `MediaAuthorizer`; see the module docs."
)]
pub fn media_router(manager: MediaManager) -> Router {
media_router_with(manager, DenyAll)
}
fn media_routes(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,
content_type: String,
headers: std::collections::BTreeMap<String, 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 tags = manager
.tags_for(id)
.await?
.into_iter()
.map(|t| t.slug)
.collect();
Ok(Self::from_row_with_tags(manager, m, tags).await)
}
async fn from_row_with_tags(manager: &MediaManager, m: Media, tags: Vec<String>) -> Self {
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;
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.into_inner().unwrap_or_else(chrono::Utc::now),
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.into_inner().unwrap_or_else(chrono::Utc::now),
}
}
}
#[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 max_list_limit() -> i64 {
super::MAX_LIST_LIMIT
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Deserialize)]
struct PageQuery {
#[serde(default = "max_list_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
#[derive(Debug, Deserialize)]
struct ContentsQuery {
#[serde(default)]
recursive: bool,
limit: Option<i64>,
#[serde(default)]
offset: i64,
}
impl IntoResponse for MediaError {
fn into_response(self) -> Response {
let status = match &self {
MediaError::UnknownDisk(_) => StatusCode::BAD_REQUEST,
MediaError::Storage(_) => StatusCode::BAD_GATEWAY,
MediaError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
MediaError::Other(m) if m.contains("not found") => StatusCode::NOT_FOUND,
MediaError::Other(_) => StatusCode::BAD_REQUEST,
};
ApiError::logged(status, "media", &self).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,
content_type: ticket.content_type,
headers: ticket.headers,
}))
}
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>>,
Query(q): Query<PageQuery>,
) -> Result<Json<Vec<CollectionResponse>>, MediaError> {
let cs = manager.list_collections_paged(q.limit, q.offset).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 = match q.limit {
Some(n) => {
manager
.list_in_collection_paged(id, q.recursive, n, q.offset)
.await?
}
None => manager.list_in_collection(id, q.recursive).await?,
};
let ids: Vec<i64> = media
.iter()
.filter_map(|m| match m.id {
crate::sql::Auto::Set(v) => Some(v),
_ => None,
})
.collect();
let mut tags = manager.tags_for_many(&ids).await?;
let mut out = Vec::with_capacity(media.len());
for m in media {
let id = match m.id {
crate::sql::Auto::Set(v) => v,
_ => 0,
};
let row_tags = tags.remove(&id).unwrap_or_default();
out.push(MediaResponse::from_row_with_tags(&manager, m, row_tags).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>>,
Query(q): Query<PageQuery>,
) -> Result<Json<Vec<TagResponse>>, MediaError> {
let tags = manager.list_tags(q.limit, q.offset).await?;
Ok(Json(tags.into_iter().map(Into::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 ids: Vec<i64> = rows
.iter()
.filter_map(|m| match m.id {
crate::sql::Auto::Set(v) => Some(v),
_ => None,
})
.collect();
let mut tags = manager.tags_for_many(&ids).await?;
let mut out = Vec::with_capacity(rows.len());
for m in rows {
let id = match m.id {
crate::sql::Auto::Set(v) => v,
_ => 0,
};
let row_tags = tags.remove(&id).unwrap_or_default();
out.push(MediaResponse::from_row_with_tags(&manager, m, row_tags).await);
}
Ok(Json(out))
}
#[allow(dead_code)]
fn _media_status_marker(_: MediaStatus) {}