rustango/media/router.rs
1//! Axum REST router for the [`MediaManager`] surface — the **internal
2//! management API**.
3//!
4//! Requires the **`admin`** feature as well as `media`.
5//!
6//! # What this router is for
7//!
8//! Uploading, deleting, moving, tagging and browsing the library, from
9//! inside your app or your admin. Every route is an operator action,
10//! and every response with a media row carries a **presigned** URL.
11//! That is why the whole surface sends `Cache-Control: no-store`.
12//!
13//! # What it is not for
14//!
15//! **Serving media to the public.** No route here is anonymous;
16//! [`MediaPerms`] answers `401` when there is no principal. A public
17//! page showing an uploaded image does not call this API. It renders a
18//! URL its own handler computed:
19//!
20//! ```ignore
21//! // your own public route, outside this router
22//! let url = manager.public_url(id).await?; // CDN address, no signature
23//! ```
24//!
25//! See [`crate::media::MediaManager::public_url`] and `docs/files.md`.
26//! Do not reach for an `AllowAll` [`MediaAuthorizer`] to make a public
27//! page work: it opens all sixteen routes, including `DELETE` and the
28//! presigned `PUT`, to everyone.
29//!
30//! # This router requires an authorization policy
31//!
32//! Build it with [`media_router_with`] and supply a
33//! [`MediaAuthorizer`]. [`media_router`] is deprecated and refuses
34//! every request — it does not serve.
35//!
36//! Without a policy these routes take no authentication and no tenant.
37//! An anonymous caller could walk the id space collecting presigned S3
38//! download links, delete rows by id, and mint a presigned PUT for any
39//! key prefix.
40//!
41//! A blanket `.layer(auth)` in front is **not** enough for a
42//! multi-tenant deployment: no handler carries a tenant, so an
43//! authenticated tenant-A user still reads tenant B's row by id.
44//! [`MediaTarget`] names the row so the decision can be made per row.
45//!
46//! Mount it under any prefix; `/media` is conventional. All responses
47//! are JSON (no auto-CSRF — wire that on the outer router via
48//! [`crate::forms::csrf`]).
49//!
50//! ## Endpoints
51//!
52//! | Method | Path | Purpose |
53//! |--------|-----------------------------------|---------|
54//! | POST | `/uploads/begin` | Start a direct browser upload — returns `{media_id, upload_url, expires_at, headers}`. |
55//! | POST | `/uploads/{id}/finalize` | Confirm the storage object landed; flips the row Pending→Ready. |
56//! | GET | `/media/{id}` | Single Media row + URL + presigned link. |
57//! | DELETE | `/media/{id}` | Soft-delete the Media row (storage preserved). |
58//! | POST | `/media/{id}/move` | Move Media to another collection: body `{collection_id?: i64}`. Authorized as `Change(MediaMove { id, collection_id })`. |
59//! | POST | `/media/{id}/tags` | Replace tag set: body `{slugs: ["a","b"]}`. |
60//! | DELETE | `/media/{id}/tags/{slug}` | Remove a single tag. |
61//! | POST | `/collections` | Create: body `{name, slug, parent_id?, description?}`. Authorized as `Add(NewCollection)`. |
62//! | GET | `/collections` | Non-deleted collections, paged. `?limit=N&offset=N` (default and max 1000). |
63//! | GET | `/collections/{id}` | Single collection. |
64//! | GET | `/collections/{id}/contents` | Media in the collection. `?recursive=true` includes sub-folders. Authorized as `Read(CollectionContents { recursive })` — a media read, not a collection read. |
65//! | DELETE | `/collections/{id}` | Soft-delete a collection **and its descendants** (Media inside is orphaned, not deleted). Authorized as `Delete(CollectionSubtree)`, not `Delete(Collection)`. |
66//! | POST | `/tags` | Create / upsert: body `{slug}`. Authorized as `Add(NewTag)`. |
67//! | GET | `/tags` | Tags by slug, paged. `?limit=N&offset=N` (default and max 1000). |
68//! | GET | `/tags/popular` | Top tags by usage count. `?limit=N`. |
69//! | GET | `/tags/{slug}/media` | Media carrying the tag. `?limit=N&offset=N`. |
70//!
71//! ## Quick start
72//!
73//! Implementing [`MediaAuthorizer`] needs the `async-trait` attribute.
74//! The crate re-exports it as [`crate::media::async_trait`], so you do
75//! not add the dependency yourself and versions cannot drift.
76//!
77//! ```ignore
78//! use rustango::media::{MediaManager, router::media_router_with};
79//! use rustango::storage::StorageRegistry;
80//!
81//! // Tables come from the framework's system migrations.
82//!
83//! // `new_pool` works on all three backends; `MediaManager::new` is
84//! // Postgres-only and takes a `PgPool`.
85//! let manager = MediaManager::new_pool(pool.clone(), registry);
86//! let app = axum::Router::new()
87//! .nest("/media", media_router_with(manager, MyAuthorizer));
88//! ```
89//!
90//! `MyAuthorizer` is yours — see [`MediaAuthorizer`]. With the
91//! **`tenancy`** feature there is a shipped one:
92//!
93//! ```ignore
94//! use rustango::media::router::{media_router_with, MediaPerms};
95//!
96//! let perms = MediaPerms::from_manager(&manager);
97//! let app = axum::Router::new()
98//! .nest("/media", media_router_with(manager, perms));
99//! ```
100//!
101//! [`MediaPerms`] checks the `{table}.{action}` permission codenames
102//! the admin already uses. It is table-level, not row-level — see its
103//! docs for what that does and does not cover.
104
105use std::sync::Arc;
106use std::time::Duration;
107
108use axum::extract::{Path, Query, State};
109use axum::http::StatusCode;
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use axum::{Json, Router};
113
114use crate::api_errors::ApiError;
115use serde::{Deserialize, Serialize};
116use serde_json::Value;
117
118use super::{
119 Media, MediaCollection, MediaError, MediaManager, MediaStatus, MediaTag, UploadIntent,
120};
121
122#[allow(dead_code)]
123const DEFAULT_PRESIGN_TTL_SECS: u64 = 3600;
124
125/// What a request names — the row an authorizer is deciding about.
126///
127/// The kind is part of the value on purpose. `/media/7` and
128/// `/collections/7` are different rows in different tables, so an
129/// authorizer handed a bare `7` could not tell them apart.
130#[derive(Debug, Clone, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum MediaTarget {
133 /// One media row, by id.
134 Media(i64),
135 /// One collection row, by id — `GET /collections/{id}`.
136 Collection(i64),
137 /// The **media inside** one collection — `GET
138 /// /collections/{id}/contents`, with or without `?recursive`.
139 ///
140 /// Separate from [`Self::Collection`] because this route returns
141 /// media rows, each with a presigned GET URL. Granting it is a
142 /// decision about media, not just about browsing folders. The id
143 /// still names a collection, so a policy that scopes collections by
144 /// owner can use it the same way it uses [`Self::Collection`].
145 ///
146 /// **`recursive` widens the same read.** It also returns the media
147 /// in every descendant collection, so it is always more rows. Any
148 /// policy must be at least as strict for `recursive: true` as for
149 /// `recursive: false`.
150 ///
151 /// Match as `MediaTarget::CollectionContents { id, recursive }`, or
152 /// `{ id, .. }` when the width does not change the answer.
153 CollectionContents {
154 /// The collection named in the path.
155 id: i64,
156 /// `true` when the request carries `?recursive`, and so reaches
157 /// media in descendant collections as well.
158 recursive: bool,
159 },
160 /// A collection **and everything under it** — what
161 /// `DELETE /collections/{id}` really reaches.
162 ///
163 /// Deleting a collection soft-deletes every descendant collection
164 /// and sets `collection_id = NULL` on the media in all of them. One
165 /// authorized id can destroy a subtree of any size. The caller does
166 /// not control that shape either: `POST /collections` takes
167 /// `parent_id`, so a collection can be attached under someone else's.
168 ///
169 /// **Granting this is not the same as granting `Delete(Collection)`.**
170 /// A policy that ends on `_ => false` denies it until someone opts
171 /// in.
172 CollectionSubtree(i64),
173 /// One tag, by slug. A slug is caller-chosen text — including
174 /// text that looks like a number — so it is never an id.
175 Tag(String),
176 /// `POST /uploads/begin`. Names no existing row: it mints a
177 /// presigned `PUT` for a caller-chosen disk and key prefix.
178 ///
179 /// **Grant this narrowly.** It is a write primitive into storage.
180 /// `/uploads/{id}/finalize` is *not* this — it mutates an existing
181 /// row and classifies as `Change(Media(id))`.
182 ///
183 /// The fields are what the caller **asked for**, read from the body
184 /// before the handler runs, so a policy can allow-list a disk, pin a
185 /// key prefix per tenant, or refuse an upload attributed to someone
186 /// else. They are **unvalidated input**, not facts: a body that does
187 /// not parse arrives as empty strings and `None`, because
188 /// authorization runs before validation. The handler rejects a bad
189 /// body afterwards.
190 ///
191 /// Match it as `MediaTarget::NewUpload { disk, .. }`, with the
192 /// trailing `..`.
193 #[non_exhaustive]
194 NewUpload {
195 /// Requested `disk` — a key into the [`crate::storage::StorageRegistry`].
196 disk: String,
197 /// Requested `key_prefix`, prepended to the generated object key.
198 key_prefix: String,
199 /// Requested `collection_id`, or `None` for the library root.
200 collection_id: Option<i64>,
201 /// Requested `uploaded_by_id`. Attribution is caller-supplied on
202 /// this route, so a policy should check it against the principal;
203 /// `MediaPerms` refuses another user's id unless superuser.
204 uploaded_by_id: Option<i64>,
205 },
206 /// `POST /collections`. Names no existing row: it creates one,
207 /// optionally under a `parent_id` from the body.
208 ///
209 /// That `parent_id` is why it is its own decision. Collections
210 /// nest, and `DELETE /collections/{id}` takes a whole subtree — see
211 /// [`Self::CollectionSubtree`].
212 ///
213 /// Match as `MediaTarget::NewCollection { .. }`, with the braces.
214 #[non_exhaustive]
215 NewCollection {
216 /// Requested `parent_id`, read from the body like
217 /// [`Self::NewUpload`]'s fields: unvalidated input.
218 parent_id: Option<i64>,
219 },
220 /// `POST /media/{id}/move`: changes media `id`, filing it into
221 /// `collection_id` (`None` = library root).
222 ///
223 /// Not [`Self::Media`], so a policy can check the destination too;
224 /// `collection_id` is read from the body and unvalidated. Match as
225 /// `MediaTarget::MediaMove { id, .. }`.
226 #[non_exhaustive]
227 MediaMove {
228 /// The media row being moved.
229 id: i64,
230 /// Requested destination collection.
231 collection_id: Option<i64>,
232 },
233 /// `POST /tags`. Creates (or upserts) a tag by slug.
234 ///
235 /// Separate from [`Self::NewCollection`]: different table,
236 /// different decision. Match as `MediaTarget::NewTag { .. }`, with
237 /// the braces.
238 #[non_exhaustive]
239 NewTag {},
240 /// A read that enumerates rather than naming a row — `GET
241 /// /collections`, `GET /tags` and `GET /tags/popular`.
242 ///
243 /// A `?recursive` contents listing is **not** one of these; it
244 /// names a collection, so it keeps the id. See
245 /// [`Self::CollectionContents`].
246 ///
247 /// **Granting this is not harmless.** Enumeration turns "guess an
248 /// id" into "read the index".
249 Listing,
250}
251
252/// What a request is trying to do, handed to a [`MediaAuthorizer`].
253///
254/// The four verbs line up with the `view` / `add` / `change` / `delete`
255/// codenames used everywhere else in this codebase, so a policy built
256/// on permissions maps onto them one-to-one.
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub enum MediaAction {
260 /// Read a row or a listing.
261 Read(MediaTarget),
262 /// Create: a collection, a tag, or an upload ticket.
263 Add(MediaTarget),
264 /// Mutate an existing row — move, (un)tag, finalize an upload.
265 Change(MediaTarget),
266 /// Destroy an existing row.
267 Delete(MediaTarget),
268}
269
270/// What a [`MediaAuthorizer`] decided, and so what the client is told.
271///
272/// Three-valued rather than `bool`: "nobody is signed in" and "signed
273/// in, but may not do this" are different answers. A token client
274/// treats `401` as its cue to refresh, so answering `403` to an
275/// anonymous request silently logs the member out.
276///
277/// `From<bool>` is implemented, so a policy that already computes a
278/// boolean can `return allowed.into()`; `false` becomes
279/// [`Self::Forbidden`].
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281#[non_exhaustive]
282pub enum MediaDecision {
283 /// Authorized — run the handler.
284 Allow,
285 /// No authenticated principal. The client is told `401` so it knows
286 /// to authenticate.
287 ///
288 /// Return this only when there is no identity at all. A signed-in
289 /// user who lacks the permission is [`Self::Forbidden`]; answering
290 /// `401` there sends a token client into a refresh loop that cannot
291 /// succeed.
292 Unauthenticated,
293 /// There is a principal (or the policy does not care), and the
294 /// answer is no. `403`.
295 Forbidden,
296}
297
298impl From<bool> for MediaDecision {
299 /// `true` → [`MediaDecision::Allow`], `false` →
300 /// [`MediaDecision::Forbidden`].
301 ///
302 /// Never `Unauthenticated`: a bare `false` says nothing about
303 /// whether a principal existed.
304 fn from(allowed: bool) -> Self {
305 if allowed {
306 Self::Allow
307 } else {
308 Self::Forbidden
309 }
310 }
311}
312
313/// Decides whether a request may touch the media surface.
314///
315/// **There is no default implementation, on purpose.** Without a
316/// policy these 16 routes take no identity and no tenant, so an
317/// anonymous caller could harvest presigned S3 links, delete by id,
318/// and mint a presigned PUT for any key prefix.
319///
320/// Implement it against whatever your app uses for identity, and scope
321/// by tenant here if you are multi-tenant — [`MediaTarget`] names the
322/// row for exactly that.
323///
324/// ```ignore
325/// struct SessionAuthorizer;
326///
327/// // Re-exported by the crate — no `async-trait` dependency of your own.
328/// #[rustango::media::async_trait]
329/// impl MediaAuthorizer for SessionAuthorizer {
330/// async fn authorize(&self, parts: &Parts, action: MediaAction) -> MediaDecision {
331/// // No identity at all is 401, not 403 — the client is being
332/// // told to authenticate, not that it may never do this.
333/// let Some(user) = current_user(parts) else {
334/// return MediaDecision::Unauthenticated;
335/// };
336/// let allowed = match action {
337/// MediaAction::Read(t) => match t {
338/// MediaTarget::Media(id) => user.may_read_media(id).await,
339/// MediaTarget::Collection(id) => user.may_read_collection(id).await,
340/// MediaTarget::Tag(slug) => user.may_read_tag(&slug).await,
341/// // Listings enumerate the tenant's whole library, so
342/// // they are an explicit decision, not a default.
343/// MediaTarget::Listing => user.may_browse_library(),
344/// _ => false,
345/// },
346/// // `NewUpload` mints a presigned PUT for a disk and key
347/// // prefix the *caller* chooses — and hands you both, so
348/// // the grant can be "this disk, under your own prefix"
349/// // rather than "anywhere in any bucket".
350/// MediaAction::Add(MediaTarget::NewUpload { disk, key_prefix, .. }) => {
351/// user.is_trusted_uploader()
352/// && disk == "user-uploads"
353/// && key_prefix.starts_with(&user.prefix())
354/// }
355/// MediaAction::Add(MediaTarget::NewCollection { .. }) => user.is_editor(),
356/// MediaAction::Add(MediaTarget::NewTag { .. }) => user.is_editor(),
357/// MediaAction::Change(MediaTarget::Media(id)) => user.owns_media(id).await,
358/// MediaAction::Change(MediaTarget::MediaMove { id, .. }) => user.owns_media(id).await,
359/// MediaAction::Delete(MediaTarget::Media(id)) => user.owns_media(id).await,
360/// // Deleting a collection takes its whole subtree and
361/// // orphans the media at every level, so it is a separate
362/// // decision from deleting one row. Left to `_ => false`
363/// // here: opt in only where you mean it.
364/// _ => false,
365/// };
366/// allowed.into()
367/// }
368/// }
369/// ```
370///
371/// Note the trailing `_ => false` arms. [`MediaAction`] and
372/// [`MediaTarget`] are both `#[non_exhaustive]`, so a future route
373/// reaches your policy as a variant you have no arm for. Ending on
374/// `false` denies it.
375///
376/// # Your impl runs on every request, and nothing bounds it
377///
378/// - **There is no timeout here.** A policy that hangs pins its
379/// request. The gate touches no pool, but a policy that queries holds
380/// a connection from its own pool while it hangs. Mount
381/// [`crate::request_timeout`] on the outer router for a bound.
382/// - **Do not hold a lock across the `.await`.** "Check a cache, else
383/// hit the database" compiles, but a `tokio::sync::Mutex` held across
384/// the await serializes the whole media surface. Take the lock, read,
385/// drop it, then await.
386#[async_trait::async_trait]
387pub trait MediaAuthorizer: Send + Sync + 'static {
388 /// [`MediaDecision::Allow`] to run the handler;
389 /// [`MediaDecision::Unauthenticated`] for `401`;
390 /// [`MediaDecision::Forbidden`] for `403`.
391 ///
392 /// A policy that already computes a boolean can return
393 /// `allowed.into()`; `false` is `Forbidden`.
394 async fn authorize(
395 &self,
396 parts: &axum::http::request::Parts,
397 action: MediaAction,
398 ) -> MediaDecision;
399}
400
401/// So a host that picks its policy at runtime can pass
402/// `Arc<dyn MediaAuthorizer>`, which the bare generic bound rejects.
403#[async_trait::async_trait]
404impl<T: MediaAuthorizer + ?Sized> MediaAuthorizer for Arc<T> {
405 async fn authorize(
406 &self,
407 parts: &axum::http::request::Parts,
408 action: MediaAction,
409 ) -> MediaDecision {
410 (**self).authorize(parts, action).await
411 }
412}
413
414/// Refuses everything. What [`media_router`] uses.
415struct DenyAll;
416
417#[async_trait::async_trait]
418impl MediaAuthorizer for DenyAll {
419 async fn authorize(&self, _: &axum::http::request::Parts, _: MediaAction) -> MediaDecision {
420 // `Forbidden`, not `Unauthenticated`: it refuses whoever asks,
421 // so telling a client to authenticate would invite a retry loop.
422 MediaDecision::Forbidden
423 }
424}
425
426/// The permission codenames a [`MediaPerms`] request must satisfy —
427/// **all** of them, not any.
428///
429/// `None` means "no mapping", which [`MediaPerms`] treats as a
430/// refusal. A future `MediaTarget` lands there, so a new route cannot
431/// inherit a grant written before it existed.
432///
433/// Public and free-standing so the mapping is testable without
434/// standing a router up.
435#[cfg(feature = "tenancy")]
436#[must_use]
437pub fn required_codenames(action: &MediaAction) -> Option<&'static [&'static str]> {
438 use MediaAction as A;
439 use MediaTarget as T;
440 Some(match action {
441 // `GET /tags/{slug}/media` returns **media** rows, so it is a
442 // media read. The tag is the filter, not the subject.
443 A::Read(T::Media(_) | T::Tag(_)) => &["rustango_media.view"],
444 A::Read(T::Collection(_)) => &["rustango_media_collections.view"],
445 // Contents returns media rows, so it takes the media permission
446 // — plus the collection one, since the id names a collection
447 // the caller must be allowed to open. Both widths take the same
448 // two; `recursive` is on the variant so a custom policy can be
449 // stricter about the wide one, never looser.
450 A::Read(T::CollectionContents { .. }) => {
451 &["rustango_media_collections.view", "rustango_media.view"]
452 }
453 // `Listing` spans three tables, so it takes the widest
454 // permission of the group. Kept separate from the `Media | Tag`
455 // arm: same codename, different reason.
456 #[allow(clippy::match_same_arms)]
457 A::Read(T::Listing) => &["rustango_media.view"],
458 // Filing into a collection needs leave to open it (#2343).
459 A::Add(T::NewUpload {
460 collection_id: Some(_),
461 ..
462 }) => &["rustango_media.add", "rustango_media_collections.view"],
463 A::Add(T::NewUpload { .. }) => &["rustango_media.add"],
464 A::Add(T::NewCollection { parent_id: Some(_) }) => &[
465 "rustango_media_collections.add",
466 "rustango_media_collections.view",
467 ],
468 A::Add(T::NewCollection { .. }) => &["rustango_media_collections.add"],
469 A::Add(T::NewTag { .. }) => &["rustango_media_tags.add"],
470 // Same rule for a move as for an upload (#2343).
471 A::Change(T::MediaMove {
472 collection_id: Some(_),
473 ..
474 }) => &["rustango_media.change", "rustango_media_collections.view"],
475 A::Change(T::Media(_) | T::MediaMove { .. }) => &["rustango_media.change"],
476 A::Delete(T::Media(_)) => &["rustango_media.delete"],
477 // Both are needed. Deleting a collection soft-deletes every
478 // descendant *and* sets `collection_id = NULL` on the media in
479 // all of them, so it writes to `rustango_media` too.
480 A::Delete(T::CollectionSubtree(_)) => {
481 &["rustango_media_collections.delete", "rustango_media.change"]
482 }
483 _ => return None,
484 })
485}
486
487/// The default policy: permission codenames, the same
488/// `{table}.{action}` names the admin and `auto_create_permissions`
489/// already use.
490///
491/// This exists so the **secure** path is the one-liner. Do not reach
492/// for an `AllowAll` trait impl to clear the 403s `media_router` gives
493/// you — that is the original hole with extra steps.
494///
495/// ```ignore
496/// use rustango::media::router::{media_router_with, MediaPerms};
497///
498/// let perms = MediaPerms::from_manager(&manager);
499/// let app = axum::Router::new()
500/// .nest("/media", media_router_with(manager, perms));
501/// ```
502///
503/// Mount it **inside** [`crate::tenancy::middleware::RouterAuthExt::require_auth`],
504/// which injects the `AuthenticatedUser` this reads. Without it every
505/// request is [`MediaDecision::Unauthenticated`] — a `401`.
506///
507/// **Not `optional_auth`.** This policy has no anonymous path, so it
508/// still answers `401` to every anonymous request. `optional_auth`
509/// only helps under a **custom** [`MediaAuthorizer`] that allows some
510/// anonymous action. For a public *page*, do not mount this router at
511/// all (see the module header).
512///
513/// # What it checks
514///
515/// [`required_codenames`] has the full mapping. An upload's
516/// `uploaded_by_id`, when sent, must be the caller's own id. Superusers
517/// short-circuit to allow, except for [`Self::allow_disks`], which is
518/// checked first and applies to them too. `is_superuser` is
519/// per-tenant, and that disk list is the only thing keeping one
520/// tenant's admin out of another tenant's bucket.
521///
522/// # What it cannot check
523///
524/// **Codenames are table-level, so this is not row-level.** A grant of
525/// `rustango_media.view` reads *any* media row by id. `MediaManager`
526/// holds a single [`crate::sql::Pool`], so a multi-tenant deployment
527/// must still scope rows itself with its own [`MediaAuthorizer`]. This
528/// type is the floor, not the ceiling.
529///
530/// `auto_create_permissions` seeds the codenames during tenant
531/// provisioning and on migrate. To re-seed without a migrate cycle,
532/// use the `seed-permissions` manage command; it is a no-op on an
533/// already-populated catalog.
534///
535/// # Cost
536///
537/// One indexed permission lookup per required codename, per request,
538/// on the pool given to [`Self::new`]. A superuser pays none.
539#[cfg(feature = "tenancy")]
540pub struct MediaPerms {
541 pool: crate::sql::Pool,
542 allowed_disks: Option<Vec<String>>,
543}
544
545#[cfg(feature = "tenancy")]
546impl MediaPerms {
547 /// Check permissions against `pool`.
548 ///
549 /// Under tenancy pass the tenant's pool, not the registry's. It is
550 /// where `rustango_user_permissions` and `rustango_user_roles` are
551 /// read, so the wrong pool reads the wrong tenant's grants.
552 ///
553 /// Until you call [`Self::allow_disks`], anyone holding
554 /// `rustango_media.add` can write to every disk in the
555 /// [`crate::storage::StorageRegistry`].
556 #[must_use]
557 pub fn new(pool: crate::sql::Pool) -> Self {
558 Self {
559 pool,
560 allowed_disks: None,
561 }
562 }
563
564 /// Check permissions on the pool `manager` reads rows from, so the
565 /// grants and the rows always come from the same tenant (#1573).
566 #[must_use]
567 pub fn from_manager(manager: &MediaManager) -> Self {
568 Self::new(manager.pool().clone())
569 }
570
571 /// Restrict `POST /uploads/begin` to these disks.
572 ///
573 /// `disk` is caller-supplied and goes straight into
574 /// `StorageRegistry::disk`, and the registry is **process-wide**:
575 /// pool-per-tenant isolates the database, not the object store. So
576 /// a bare `rustango_media.add` grant mints a presigned `PUT` into
577 /// *any* registered bucket. A codename cannot say "this disk",
578 /// which is why this is a list. It is checked **in addition to**
579 /// `rustango_media.add`, never instead of it.
580 ///
581 /// **It also binds superusers**, alone among the checks here.
582 /// `is_superuser` elevates inside one tenant; this list is what
583 /// stops that reaching another tenant's storage. Leave it unset if
584 /// org admins should be exempt.
585 ///
586 /// ```ignore
587 /// MediaPerms::new(pool).allow_disks(["user-uploads"])
588 /// ```
589 ///
590 /// Prefixes within a disk are not expressible here. For "your own
591 /// prefix on a shared bucket", implement [`MediaAuthorizer`], which
592 /// is handed `key_prefix`.
593 #[must_use]
594 pub fn allow_disks<I, S>(mut self, disks: I) -> Self
595 where
596 I: IntoIterator<Item = S>,
597 S: Into<String>,
598 {
599 self.allowed_disks = Some(disks.into_iter().map(Into::into).collect());
600 self
601 }
602
603 /// `true` when `disk` may be written to. Unset means every disk —
604 /// see [`Self::allow_disks`].
605 fn disk_allowed(&self, disk: &str) -> bool {
606 match &self.allowed_disks {
607 Some(allowed) => allowed.iter().any(|d| d == disk),
608 None => true,
609 }
610 }
611}
612
613#[cfg(feature = "tenancy")]
614#[async_trait::async_trait]
615impl MediaAuthorizer for MediaPerms {
616 async fn authorize(
617 &self,
618 parts: &axum::http::request::Parts,
619 action: MediaAction,
620 ) -> MediaDecision {
621 let Some(auth) = parts
622 .extensions
623 .get::<crate::tenancy::middleware::AuthenticatedUser>()
624 else {
625 // No principal at all — 401, so a token client refreshes
626 // instead of treating the refusal as final. It also names
627 // the mistake of mounting outside `require_auth`.
628 return MediaDecision::Unauthenticated;
629 };
630 // The disk allow-list runs **ahead of the superuser
631 // short-circuit**, and it is the only check that does.
632 // `is_superuser` is per-tenant, and `StorageRegistry` is
633 // process-wide, so short-circuiting first would let any
634 // tenant's admin mint a presigned PUT into another tenant's
635 // bucket. It is applied on top of the codename, never instead
636 // of it. Leave `allow_disks` unset to exempt admins.
637 if let MediaAction::Add(MediaTarget::NewUpload { disk, .. }) = &action {
638 if !self.disk_allowed(disk) {
639 tracing::debug!(
640 target: "rustango::media::auth",
641 disk = %disk,
642 user_id = auth.id,
643 superuser = auth.is_superuser,
644 "MediaPerms: upload ticket refused — disk not in the allow-list"
645 );
646 return MediaDecision::Forbidden;
647 }
648 }
649 if auth.is_superuser {
650 return MediaDecision::Allow;
651 }
652 // Only a superuser may attribute an upload to someone else (#2343).
653 if let MediaAction::Add(MediaTarget::NewUpload {
654 uploaded_by_id: Some(by),
655 ..
656 }) = &action
657 {
658 if *by != auth.id {
659 return MediaDecision::Forbidden;
660 }
661 }
662 // A variant with no mapping is a route added after this policy
663 // was written. Denying it is the point.
664 let Some(codenames) = required_codenames(&action) else {
665 tracing::debug!(
666 target: "rustango::media::auth",
667 ?action,
668 "MediaPerms has no codename for this action — refusing"
669 );
670 return MediaDecision::Forbidden;
671 };
672 // An empty slice would fall straight through the loop to
673 // `Allow`. A mapping that requires nothing is a mistake, not a
674 // grant.
675 if codenames.is_empty() {
676 tracing::warn!(
677 target: "rustango::media::auth",
678 ?action,
679 "MediaPerms: empty codename list — refusing rather than allowing"
680 );
681 return MediaDecision::Forbidden;
682 }
683 for cn in codenames {
684 match crate::tenancy::permissions::has_perm_pool(auth.id, cn, &self.pool).await {
685 Ok(true) => {}
686 Ok(false) => return MediaDecision::Forbidden,
687 Err(e) => {
688 // Fail closed. A driver error is not a grant.
689 tracing::warn!(
690 target: "rustango::media::auth",
691 codename = %cn,
692 user_id = auth.id,
693 error = %e,
694 "permission lookup failed — refusing"
695 );
696 return MediaDecision::Forbidden;
697 }
698 }
699 }
700 MediaDecision::Allow
701 }
702}
703
704/// The slice of `POST /uploads/begin`'s body the gate reads, so the
705/// policy can see what the caller asked for.
706///
707/// Every field defaults, and a body that does not parse falls back to
708/// the struct's default. That is on purpose: the gate must not answer
709/// `400`. Authorization is decided first, on whatever the caller sent;
710/// the handler validates afterwards.
711#[derive(Debug, Default, Deserialize)]
712struct UploadRequestDetail {
713 #[serde(default)]
714 disk: String,
715 #[serde(default)]
716 key_prefix: String,
717 #[serde(default)]
718 collection_id: Option<i64>,
719 #[serde(default)]
720 uploaded_by_id: Option<i64>,
721}
722
723/// The most the gate will buffer from an upload or move body.
724///
725/// Those bodies are a few hundred bytes of JSON. 16 KiB is far above
726/// any real one and small enough that buffering it in an authorization
727/// layer is not a denial-of-service primitive. Only [`gate_body_limit`]
728/// routes are read; the rest stream through.
729const MAX_GATE_BODY_BYTES: usize = 16 * 1024;
730
731/// `POST /collections` carries a free-text `description`, so its cap is
732/// axum's default body limit: the gate refuses nothing the handler takes.
733const MAX_GATE_COLLECTION_BODY_BYTES: usize = 2 * 1024 * 1024;
734
735/// How much of this request's body the gate reads to classify it, or
736/// `None` to stream it through unread.
737///
738/// `POST /uploads/begin`, `POST /media/{id}/move` and `POST
739/// /collections` carry a disk, destination or parent the policy needs.
740/// Its own function because the middleware must decide whether to
741/// buffer *before* it can call [`classify`].
742fn gate_body_limit(method: &axum::http::Method, path: &str) -> Option<usize> {
743 if method != axum::http::Method::POST {
744 return None;
745 }
746 let seg: Vec<String> = path
747 .split('/')
748 .filter(|s| !s.is_empty())
749 .map(crate::url_codec::percent_decode_path)
750 .collect();
751 let s: Vec<&str> = seg.iter().map(String::as_str).collect();
752 match s.as_slice() {
753 ["uploads", "begin"] | ["media", _, "move"] => Some(MAX_GATE_BODY_BYTES),
754 ["collections"] => Some(MAX_GATE_COLLECTION_BODY_BYTES),
755 _ => None,
756 }
757}
758
759/// Classify a request so the authorizer sees what it is deciding about.
760///
761/// `None` means the request matches no route in this table, and the
762/// caller must refuse. Fail closed.
763///
764/// Segments are percent-decoded, then matched **positionally** against
765/// the route table. Both matter: scanning for the first
766/// integer-parsable segment of a raw path misses `/media/%31` and
767/// mistakes the `2024` in `/tags/2024/media` for an id.
768///
769/// `query` is read for one thing: `?recursive` on a collection's
770/// contents reaches media in descendant collections. That widening
771/// rides on [`MediaTarget::CollectionContents`] instead of replacing
772/// the target, so the id survives and no policy can ask less of the
773/// wide form than of the narrow one.
774///
775/// `body` is the buffered body of a [`gate_body_limit`] route. A body
776/// that does not parse gives a target with empty fields, not no target:
777/// it is still that request, and the policy decides. Move and collection
778/// bodies parse as the handler's own type, so the handler rejects
779/// exactly the bodies whose destination the gate could not see.
780fn classify(
781 method: &axum::http::Method,
782 path: &str,
783 query: Option<&str>,
784 body: Option<&[u8]>,
785) -> Option<MediaAction> {
786 use axum::http::Method;
787
788 fn parse<T: serde::de::DeserializeOwned>(body: Option<&[u8]>) -> Option<T> {
789 body.and_then(|b| serde_json::from_slice(b).ok())
790 }
791
792 let seg: Vec<String> = path
793 .split('/')
794 .filter(|s| !s.is_empty())
795 .map(crate::url_codec::percent_decode_path)
796 .collect();
797 let s: Vec<&str> = seg.iter().map(String::as_str).collect();
798
799 // An id that does not parse cannot name a row, so there is nothing
800 // to authorize — refuse rather than guess a target.
801 let id = |raw: &str| raw.parse::<i64>().ok();
802
803 // axum routes HEAD to the GET handler, so the gate has to agree or
804 // a documented GET route is unusable with HEAD.
805 let m = if method == Method::HEAD {
806 &Method::GET
807 } else {
808 method
809 };
810
811 // Presence, not value: naming the parameter at all is a request to
812 // widen, and the handler rejects a non-boolean itself. Being
813 // stricter than the handler is the safe direction here.
814 //
815 // The key is **decoded first**, with form semantics, to match the
816 // handler's `Query` extractor: `serde_urlencoded` parses through
817 // `form_urlencoded`, which percent-decodes the key and turns `+`
818 // into a space. Matching raw bytes let `?%72ecursive=true` reach
819 // the handler as a subtree walk while the gate saw a single read.
820 let recursive = query.is_some_and(|q| {
821 q.split('&').any(|p| {
822 let key = p.split('=').next().unwrap_or(p);
823 crate::url_codec::url_decode(key) == "recursive"
824 })
825 });
826
827 match (m, s.as_slice()) {
828 (&Method::POST, ["uploads", "begin"]) => {
829 let d = parse::<UploadRequestDetail>(body).unwrap_or_default();
830 Some(MediaAction::Add(MediaTarget::NewUpload {
831 disk: d.disk,
832 key_prefix: d.key_prefix,
833 collection_id: d.collection_id,
834 uploaded_by_id: d.uploaded_by_id,
835 }))
836 }
837 // Finalize mutates the media row it names, so it is a `Media`
838 // target rather than a kind of its own.
839 (&Method::POST, ["uploads", raw, "finalize"]) => {
840 Some(MediaAction::Change(MediaTarget::Media(id(raw)?)))
841 }
842
843 (&Method::GET, ["media", raw]) => Some(MediaAction::Read(MediaTarget::Media(id(raw)?))),
844 (&Method::DELETE, ["media", raw]) => {
845 Some(MediaAction::Delete(MediaTarget::Media(id(raw)?)))
846 }
847 // A move also names its destination, so the policy can check it.
848 (&Method::POST, ["media", raw, "move"]) => {
849 Some(MediaAction::Change(MediaTarget::MediaMove {
850 id: id(raw)?,
851 collection_id: parse::<MoveBody>(body).and_then(|b| b.collection_id),
852 }))
853 }
854 // tag / untag mutate the media row itself.
855 (&Method::POST, ["media", raw, "tags"]) | (&Method::DELETE, ["media", raw, "tags", _]) => {
856 Some(MediaAction::Change(MediaTarget::Media(id(raw)?)))
857 }
858
859 (&Method::GET, ["collections"]) => Some(MediaAction::Read(MediaTarget::Listing)),
860 (&Method::POST, ["collections"]) => Some(MediaAction::Add(MediaTarget::NewCollection {
861 parent_id: parse::<CreateCollectionBody>(body).and_then(|b| b.parent_id),
862 })),
863 // The collection row itself.
864 (&Method::GET, ["collections", raw]) => {
865 Some(MediaAction::Read(MediaTarget::Collection(id(raw)?)))
866 }
867 // The media inside it — a different table, and a presigned URL
868 // per row, so it cannot share `Collection` with the arm above.
869 // One arm for both widths: `?recursive` rides on the target as
870 // a flag so the id survives for a row-scoping policy.
871 (&Method::GET, ["collections", raw, "contents"]) => {
872 Some(MediaAction::Read(MediaTarget::CollectionContents {
873 id: id(raw)?,
874 recursive,
875 }))
876 }
877 // Not `Collection` — this route deletes the whole subtree and
878 // orphans the media at every level of it.
879 (&Method::DELETE, ["collections", raw]) => Some(MediaAction::Delete(
880 MediaTarget::CollectionSubtree(id(raw)?),
881 )),
882
883 // `popular` is a listing, and must be matched before the
884 // `{slug}` arm or a tag literally named "popular" shadows it.
885 (&Method::GET, ["tags"] | ["tags", "popular"]) => {
886 Some(MediaAction::Read(MediaTarget::Listing))
887 }
888 (&Method::POST, ["tags"]) => Some(MediaAction::Add(MediaTarget::NewTag {})),
889 (&Method::GET, ["tags", slug, "media"]) => {
890 Some(MediaAction::Read(MediaTarget::Tag((*slug).to_owned())))
891 }
892
893 _ => None,
894 }
895}
896
897/// Build the media router with an authorization policy.
898///
899/// Every route is gated: the authorizer runs before the handler, so a
900/// refusal costs no database work and never reaches the presigning
901/// code.
902pub fn media_router_with<A: MediaAuthorizer>(manager: MediaManager, authorizer: A) -> Router {
903 let auth = Arc::new(authorizer);
904 media_routes(manager)
905 .layer(axum::middleware::from_fn(
906 move |req: axum::extract::Request, next: axum::middleware::Next| {
907 let auth = Arc::clone(&auth);
908 async move {
909 let (parts, body) = req.into_parts();
910 // Three routes need their body to be classified.
911 // Buffered under a cap and handed straight back, so
912 // nothing downstream notices — except that the policy
913 // now knows the disk, destination or parent it is
914 // being asked to allow.
915 let (bytes, body) =
916 if let Some(limit) = gate_body_limit(&parts.method, parts.uri.path()) {
917 // Over the cap, or the stream failed. Refusing
918 // keeps the gate from being a place to make a
919 // server buffer.
920 let Ok(bytes) = axum::body::to_bytes(body, limit).await else {
921 tracing::debug!(
922 target: "rustango::media::auth",
923 path = %parts.uri.path(),
924 "refused: request body could not be read within the \
925 gate's limit"
926 );
927 return ApiError::forbidden("request body too large to authorize")
928 .into_response();
929 };
930 // `Bytes` clones by refcount, not by copy.
931 (Some(bytes.clone()), axum::body::Body::from(bytes))
932 } else {
933 (None, body)
934 };
935 // No classification means no route here matches, or
936 // the id cannot be a row id. Refuse rather than
937 // pass an unrecognised shape through.
938 let Some(action) = classify(
939 &parts.method,
940 parts.uri.path(),
941 parts.uri.query(),
942 bytes.as_deref(),
943 ) else {
944 tracing::debug!(
945 target: "rustango::media::auth",
946 method = %parts.method,
947 path = %parts.uri.path(),
948 "refused: no route in the media table matches"
949 );
950 return ApiError::forbidden("not a recognised media operation")
951 .into_response();
952 };
953 let decision = auth.authorize(&parts, action.clone()).await;
954 if decision != MediaDecision::Allow {
955 // `debug`, so it costs nothing in production, but
956 // a subtly-wrong policy stays debuggable.
957 tracing::debug!(
958 target: "rustango::media::auth",
959 ?action,
960 ?decision,
961 method = %parts.method,
962 path = %parts.uri.path(),
963 "refused by the authorization policy"
964 );
965 // 401 means "authenticate", 403 means "you may
966 // not". A token client treats 401 as its cue to
967 // refresh.
968 return match decision {
969 MediaDecision::Unauthenticated => {
970 ApiError::unauthorized("authentication required")
971 }
972 _ => ApiError::forbidden("not authorized for this media operation"),
973 }
974 .into_response();
975 }
976 next.run(axum::extract::Request::from_parts(parts, body))
977 .await
978 }
979 },
980 ))
981 // Outermost, so it also covers the gate's own early 403s: the
982 // last `.layer` wraps the ones before it.
983 .layer(axum::middleware::from_fn(no_store))
984}
985
986/// `Cache-Control: no-store` and `Vary` on every response.
987///
988/// These routes hand out **presigned URLs** — short-lived bearer
989/// credentials that work for anyone holding them until the TTL
990/// expires. Without a directive, a 200 `GET` with no explicit
991/// freshness is heuristically cacheable (RFC 9111 §4.2.2), and with no
992/// `Vary` the cache key is just method plus URI.
993///
994/// RFC 9111 §3.5 keeps a shared cache off a response whose request
995/// carried `Authorization`, but says **nothing about `Cookie`** — and
996/// the authorizer this module documents is cookie/session shaped. So a
997/// CDN could store one user's signed link and serve it to the next
998/// caller of the same URI.
999///
1000/// Applied to the whole surface, not just the routes that embed a
1001/// signature today, so a new route cannot opt out by accident.
1002/// `no-store` rather than `private`, because `private` still lets a
1003/// browser keep the credential on disk.
1004///
1005/// # Scope
1006///
1007/// This covers the JSON **carrying** a presigned URL, not the later
1008/// fetch of the object through that URL — a different request to a
1009/// different origin, under its own cache rules.
1010///
1011/// Mounted with `nest`, an unmatched path under the prefix is answered
1012/// by the *outer* router's fallback and carries neither header.
1013/// Nothing is served there, so no credential leaks. Adding a
1014/// `.fallback()` here would close that, but `Router::merge` does not
1015/// reject two fallbacks, so an integrator who merges would silently
1016/// get 403 for every unknown URL in their app.
1017async fn no_store(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
1018 let mut resp = next.run(req).await;
1019 // `insert`, not `append`. A handler that set its own `Cache-Control`
1020 // would otherwise leave two directives on the response, and the
1021 // weaker one could win.
1022 resp.headers_mut().insert(
1023 axum::http::header::CACHE_CONTROL,
1024 axum::http::HeaderValue::from_static("no-store"),
1025 );
1026 // `Vary` as well, because it works differently: `no-store` asks a
1027 // cache not to store, while `Vary` changes the cache key. CDNs
1028 // routinely override origin directives (nginx
1029 // `proxy_ignore_headers`, Cloudflare "Cache Everything", Fastly
1030 // VCL), and each of those drops `no-store`. Ignoring `Vary` takes
1031 // a separate, deliberate step. Losing hit rate here is the point.
1032 //
1033 // Both header names, because the authorizer reads `Parts` and may
1034 // key off either.
1035 resp.headers_mut().insert(
1036 axum::http::header::VARY,
1037 axum::http::HeaderValue::from_static("Cookie, Authorization"),
1038 );
1039 resp
1040}
1041
1042/// Build the media router. Pass to `axum::Router::nest("/media", ...)`.
1043///
1044/// # This refuses every request
1045///
1046/// It mounts a refuse-everything policy, so every route answers `403`.
1047/// Use [`media_router_with`] and supply a [`MediaAuthorizer`].
1048#[deprecated(
1049 since = "0.57.7",
1050 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."
1051)]
1052pub fn media_router(manager: MediaManager) -> Router {
1053 media_router_with(manager, DenyAll)
1054}
1055
1056fn media_routes(manager: MediaManager) -> Router {
1057 let state = Arc::new(manager);
1058 Router::new()
1059 .route("/uploads/begin", post(begin_upload_handler))
1060 .route("/uploads/{id}/finalize", post(finalize_upload_handler))
1061 .route(
1062 "/media/{id}",
1063 get(get_media_handler).delete(delete_media_handler),
1064 )
1065 .route("/media/{id}/move", post(move_media_handler))
1066 .route("/media/{id}/tags", post(set_tags_handler))
1067 .route("/media/{id}/tags/{slug}", delete(untag_handler))
1068 .route(
1069 "/collections",
1070 post(create_collection_handler).get(list_collections_handler),
1071 )
1072 .route(
1073 "/collections/{id}",
1074 get(get_collection_handler).delete(delete_collection_handler),
1075 )
1076 .route(
1077 "/collections/{id}/contents",
1078 get(collection_contents_handler),
1079 )
1080 .route("/tags", post(create_tag_handler).get(list_tags_handler))
1081 .route("/tags/popular", get(popular_tags_handler))
1082 .route("/tags/{slug}/media", get(media_with_tag_handler))
1083 .with_state(state)
1084}
1085
1086// =====================================================================
1087// Wire types
1088// =====================================================================
1089
1090#[derive(Debug, Deserialize)]
1091struct BeginUploadBody {
1092 disk: String,
1093 #[serde(default)]
1094 key_prefix: String,
1095 mime: String,
1096 original_filename: String,
1097 size_bytes: i64,
1098 #[serde(default)]
1099 uploaded_by_id: Option<i64>,
1100 #[serde(default)]
1101 collection_id: Option<i64>,
1102 #[serde(default = "default_ttl_secs")]
1103 ttl_secs: u64,
1104}
1105
1106fn default_ttl_secs() -> u64 {
1107 300
1108}
1109
1110#[derive(Debug, Serialize)]
1111struct UploadTicketBody {
1112 media_id: i64,
1113 upload_url: String,
1114 expires_at: chrono::DateTime<chrono::Utc>,
1115 disk: String,
1116 storage_key: String,
1117 /// The `Content-Type` header the PUT must carry.
1118 content_type: String,
1119 /// Every header the PUT must carry.
1120 headers: std::collections::BTreeMap<String, String>,
1121}
1122
1123#[derive(Debug, Serialize)]
1124struct MediaResponse {
1125 id: i64,
1126 disk: String,
1127 storage_key: String,
1128 mime: String,
1129 size_bytes: i64,
1130 original_filename: String,
1131 status: String,
1132 uploaded_at: chrono::DateTime<chrono::Utc>,
1133 uploaded_by_id: Option<i64>,
1134 derived_from_id: Option<i64>,
1135 collection_id: Option<i64>,
1136 metadata: Value,
1137 /// CDN-aware public URL when available.
1138 url: Option<String>,
1139 /// Time-limited GET URL (1h by default), if the backend signs.
1140 presigned_url: Option<String>,
1141 tags: Vec<String>,
1142}
1143
1144impl MediaResponse {
1145 async fn from_row(manager: &MediaManager, m: Media) -> Result<Self, MediaError> {
1146 let id = match m.id {
1147 crate::sql::Auto::Set(v) => v,
1148 _ => 0,
1149 };
1150 let tags = manager
1151 .tags_for(id)
1152 .await?
1153 .into_iter()
1154 .map(|t| t.slug)
1155 .collect();
1156 Ok(Self::from_row_with_tags(manager, m, tags).await)
1157 }
1158
1159 /// [`Self::from_row`] with the tag slugs already in hand.
1160 ///
1161 /// Lets a listing fetch tags for the whole page in one query
1162 /// instead of one per row. Infallible, because the only fallible
1163 /// step in `from_row` was that per-row tag query.
1164 async fn from_row_with_tags(manager: &MediaManager, m: Media, tags: Vec<String>) -> Self {
1165 let id = match m.id {
1166 crate::sql::Auto::Set(v) => v,
1167 _ => 0,
1168 };
1169 let url = manager.url(&m);
1170 let presigned = manager
1171 .presigned_get(&m, Duration::from_secs(DEFAULT_PRESIGN_TTL_SECS))
1172 .await;
1173 Self {
1174 id,
1175 disk: m.disk,
1176 storage_key: m.storage_key,
1177 mime: m.mime,
1178 size_bytes: m.size_bytes,
1179 original_filename: m.original_filename,
1180 status: m.status,
1181 uploaded_at: m.uploaded_at.into_inner().unwrap_or_else(chrono::Utc::now),
1182 uploaded_by_id: m.uploaded_by_id,
1183 derived_from_id: m.derived_from_id,
1184 collection_id: m.collection_id,
1185 metadata: m.metadata,
1186 url,
1187 presigned_url: presigned,
1188 tags,
1189 }
1190 }
1191}
1192
1193#[derive(Debug, Deserialize)]
1194struct MoveBody {
1195 collection_id: Option<i64>,
1196}
1197
1198#[derive(Debug, Deserialize)]
1199struct SetTagsBody {
1200 slugs: Vec<String>,
1201}
1202
1203#[derive(Debug, Deserialize)]
1204struct CreateCollectionBody {
1205 name: String,
1206 slug: String,
1207 #[serde(default)]
1208 parent_id: Option<i64>,
1209 #[serde(default)]
1210 description: String,
1211}
1212
1213#[derive(Debug, Serialize)]
1214struct CollectionResponse {
1215 id: i64,
1216 name: String,
1217 slug: String,
1218 parent_id: Option<i64>,
1219 description: String,
1220 created_at: chrono::DateTime<chrono::Utc>,
1221}
1222
1223impl From<MediaCollection> for CollectionResponse {
1224 fn from(c: MediaCollection) -> Self {
1225 let id = match c.id {
1226 crate::sql::Auto::Set(v) => v,
1227 _ => 0,
1228 };
1229 Self {
1230 id,
1231 name: c.name,
1232 slug: c.slug,
1233 parent_id: c.parent_id,
1234 description: c.description,
1235 created_at: c.created_at.into_inner().unwrap_or_else(chrono::Utc::now),
1236 }
1237 }
1238}
1239
1240#[derive(Debug, Deserialize)]
1241struct CreateTagBody {
1242 slug: String,
1243}
1244
1245#[derive(Debug, Serialize)]
1246struct TagResponse {
1247 id: i64,
1248 name: String,
1249 slug: String,
1250}
1251
1252impl From<MediaTag> for TagResponse {
1253 fn from(t: MediaTag) -> Self {
1254 let id = match t.id {
1255 crate::sql::Auto::Set(v) => v,
1256 _ => 0,
1257 };
1258 Self {
1259 id,
1260 name: t.name,
1261 slug: t.slug,
1262 }
1263 }
1264}
1265
1266#[derive(Debug, Deserialize)]
1267struct PopularQuery {
1268 #[serde(default = "default_limit")]
1269 limit: i64,
1270}
1271
1272#[derive(Debug, Deserialize)]
1273struct ListWithTagQuery {
1274 #[serde(default = "default_limit")]
1275 limit: i64,
1276 #[serde(default)]
1277 offset: i64,
1278}
1279
1280fn max_list_limit() -> i64 {
1281 super::MAX_LIST_LIMIT
1282}
1283
1284fn default_limit() -> i64 {
1285 50
1286}
1287
1288#[derive(Debug, Deserialize)]
1289struct PageQuery {
1290 /// Clamped server-side to `1..=1000`. Absent means 1000 in 0.60.x,
1291 /// so no client gets fewer rows than before; 100 from 0.61.0.
1292 #[serde(default = "max_list_limit")]
1293 limit: i64,
1294 #[serde(default)]
1295 offset: i64,
1296}
1297
1298#[derive(Debug, Deserialize)]
1299struct ContentsQuery {
1300 #[serde(default)]
1301 recursive: bool,
1302 /// Clamped server-side to `1..=1000`; absent means the default page.
1303 limit: Option<i64>,
1304 #[serde(default)]
1305 offset: i64,
1306}
1307
1308// =====================================================================
1309// Error mapping
1310// =====================================================================
1311
1312impl IntoResponse for MediaError {
1313 fn into_response(self) -> Response {
1314 let status = match &self {
1315 MediaError::UnknownDisk(_) => StatusCode::BAD_REQUEST,
1316 MediaError::Storage(_) => StatusCode::BAD_GATEWAY,
1317 MediaError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
1318 MediaError::Other(m) if m.contains("not found") => StatusCode::NOT_FOUND,
1319 MediaError::Other(_) => StatusCode::BAD_REQUEST,
1320 };
1321 ApiError::logged(status, "media", &self).into_response()
1322 }
1323}
1324
1325// =====================================================================
1326// Handlers
1327// =====================================================================
1328
1329async fn begin_upload_handler(
1330 State(manager): State<Arc<MediaManager>>,
1331 Json(body): Json<BeginUploadBody>,
1332) -> Result<Json<UploadTicketBody>, MediaError> {
1333 let ticket = manager
1334 .begin_upload(UploadIntent {
1335 disk: body.disk,
1336 key_prefix: body.key_prefix,
1337 mime: body.mime,
1338 original_filename: body.original_filename,
1339 size_bytes: body.size_bytes,
1340 uploaded_by_id: body.uploaded_by_id,
1341 collection_id: body.collection_id,
1342 ttl: Duration::from_secs(body.ttl_secs.clamp(60, 3600)),
1343 })
1344 .await?;
1345 Ok(Json(UploadTicketBody {
1346 media_id: ticket.media_id,
1347 upload_url: ticket.upload_url,
1348 expires_at: ticket.expires_at,
1349 disk: ticket.disk,
1350 storage_key: ticket.storage_key,
1351 content_type: ticket.content_type,
1352 headers: ticket.headers,
1353 }))
1354}
1355
1356async fn finalize_upload_handler(
1357 State(manager): State<Arc<MediaManager>>,
1358 Path(id): Path<i64>,
1359) -> Result<Json<MediaResponse>, MediaError> {
1360 let m = manager.finalize_upload(id).await?;
1361 let resp = MediaResponse::from_row(&manager, m).await?;
1362 Ok(Json(resp))
1363}
1364
1365async fn get_media_handler(
1366 State(manager): State<Arc<MediaManager>>,
1367 Path(id): Path<i64>,
1368) -> Result<Json<MediaResponse>, MediaError> {
1369 let m = manager
1370 .get(id)
1371 .await?
1372 .ok_or_else(|| MediaError::Other(format!("media {id} not found")))?;
1373 let resp = MediaResponse::from_row(&manager, m).await?;
1374 Ok(Json(resp))
1375}
1376
1377async fn delete_media_handler(
1378 State(manager): State<Arc<MediaManager>>,
1379 Path(id): Path<i64>,
1380) -> Result<StatusCode, MediaError> {
1381 let m = manager
1382 .get(id)
1383 .await?
1384 .ok_or_else(|| MediaError::Other(format!("media {id} not found")))?;
1385 manager.delete(&m).await?;
1386 Ok(StatusCode::NO_CONTENT)
1387}
1388
1389async fn move_media_handler(
1390 State(manager): State<Arc<MediaManager>>,
1391 Path(id): Path<i64>,
1392 Json(body): Json<MoveBody>,
1393) -> Result<StatusCode, MediaError> {
1394 manager.move_to_collection(id, body.collection_id).await?;
1395 Ok(StatusCode::NO_CONTENT)
1396}
1397
1398async fn set_tags_handler(
1399 State(manager): State<Arc<MediaManager>>,
1400 Path(id): Path<i64>,
1401 Json(body): Json<SetTagsBody>,
1402) -> Result<StatusCode, MediaError> {
1403 let slugs: Vec<&str> = body.slugs.iter().map(String::as_str).collect();
1404 manager.set_tags(id, &slugs).await?;
1405 Ok(StatusCode::NO_CONTENT)
1406}
1407
1408async fn untag_handler(
1409 State(manager): State<Arc<MediaManager>>,
1410 Path((id, slug)): Path<(i64, String)>,
1411) -> Result<StatusCode, MediaError> {
1412 manager.untag(id, &slug).await?;
1413 Ok(StatusCode::NO_CONTENT)
1414}
1415
1416async fn create_collection_handler(
1417 State(manager): State<Arc<MediaManager>>,
1418 Json(body): Json<CreateCollectionBody>,
1419) -> Result<(StatusCode, Json<CollectionResponse>), MediaError> {
1420 let c = manager
1421 .create_collection(body.name, body.slug, body.parent_id, body.description)
1422 .await?;
1423 Ok((StatusCode::CREATED, Json(c.into())))
1424}
1425
1426async fn list_collections_handler(
1427 State(manager): State<Arc<MediaManager>>,
1428 Query(q): Query<PageQuery>,
1429) -> Result<Json<Vec<CollectionResponse>>, MediaError> {
1430 let cs = manager.list_collections_paged(q.limit, q.offset).await?;
1431 Ok(Json(cs.into_iter().map(Into::into).collect()))
1432}
1433
1434async fn get_collection_handler(
1435 State(manager): State<Arc<MediaManager>>,
1436 Path(id): Path<i64>,
1437) -> Result<Json<CollectionResponse>, MediaError> {
1438 let c = manager
1439 .get_collection(id)
1440 .await?
1441 .ok_or_else(|| MediaError::Other(format!("collection {id} not found")))?;
1442 Ok(Json(c.into()))
1443}
1444
1445async fn delete_collection_handler(
1446 State(manager): State<Arc<MediaManager>>,
1447 Path(id): Path<i64>,
1448) -> Result<StatusCode, MediaError> {
1449 manager.delete_collection(id).await?;
1450 Ok(StatusCode::NO_CONTENT)
1451}
1452
1453async fn collection_contents_handler(
1454 State(manager): State<Arc<MediaManager>>,
1455 Path(id): Path<i64>,
1456 Query(q): Query<ContentsQuery>,
1457) -> Result<Json<Vec<MediaResponse>>, MediaError> {
1458 let media = match q.limit {
1459 Some(n) => {
1460 manager
1461 .list_in_collection_paged(id, q.recursive, n, q.offset)
1462 .await?
1463 }
1464 None => manager.list_in_collection(id, q.recursive).await?,
1465 };
1466 // One tag query for the whole page, not one per row.
1467 let ids: Vec<i64> = media
1468 .iter()
1469 .filter_map(|m| match m.id {
1470 crate::sql::Auto::Set(v) => Some(v),
1471 _ => None,
1472 })
1473 .collect();
1474 let mut tags = manager.tags_for_many(&ids).await?;
1475 let mut out = Vec::with_capacity(media.len());
1476 for m in media {
1477 let id = match m.id {
1478 crate::sql::Auto::Set(v) => v,
1479 _ => 0,
1480 };
1481 let row_tags = tags.remove(&id).unwrap_or_default();
1482 out.push(MediaResponse::from_row_with_tags(&manager, m, row_tags).await);
1483 }
1484 Ok(Json(out))
1485}
1486
1487async fn create_tag_handler(
1488 State(manager): State<Arc<MediaManager>>,
1489 Json(body): Json<CreateTagBody>,
1490) -> Result<(StatusCode, Json<TagResponse>), MediaError> {
1491 let t = manager.ensure_tag(&body.slug).await?;
1492 Ok((StatusCode::CREATED, Json(t.into())))
1493}
1494
1495async fn list_tags_handler(
1496 State(manager): State<Arc<MediaManager>>,
1497 Query(q): Query<PageQuery>,
1498) -> Result<Json<Vec<TagResponse>>, MediaError> {
1499 let tags = manager.list_tags(q.limit, q.offset).await?;
1500 Ok(Json(tags.into_iter().map(Into::into).collect()))
1501}
1502
1503async fn popular_tags_handler(
1504 State(manager): State<Arc<MediaManager>>,
1505 Query(q): Query<PopularQuery>,
1506) -> Result<Json<Vec<PopularTagEntry>>, MediaError> {
1507 let pairs = manager.popular_tags(q.limit).await?;
1508 let resp = pairs
1509 .into_iter()
1510 .map(|(t, count)| PopularTagEntry {
1511 tag: t.into(),
1512 use_count: count,
1513 })
1514 .collect();
1515 Ok(Json(resp))
1516}
1517
1518#[derive(Debug, Serialize)]
1519struct PopularTagEntry {
1520 #[serde(flatten)]
1521 tag: TagResponse,
1522 use_count: i64,
1523}
1524
1525async fn media_with_tag_handler(
1526 State(manager): State<Arc<MediaManager>>,
1527 Path(slug): Path<String>,
1528 Query(q): Query<ListWithTagQuery>,
1529) -> Result<Json<Vec<MediaResponse>>, MediaError> {
1530 let rows = manager.list_with_tag(&slug, q.limit, q.offset).await?;
1531 // One tag query for the whole page, same as the contents listing.
1532 let ids: Vec<i64> = rows
1533 .iter()
1534 .filter_map(|m| match m.id {
1535 crate::sql::Auto::Set(v) => Some(v),
1536 _ => None,
1537 })
1538 .collect();
1539 let mut tags = manager.tags_for_many(&ids).await?;
1540 let mut out = Vec::with_capacity(rows.len());
1541 for m in rows {
1542 let id = match m.id {
1543 crate::sql::Auto::Set(v) => v,
1544 _ => 0,
1545 };
1546 let row_tags = tags.remove(&id).unwrap_or_default();
1547 out.push(MediaResponse::from_row_with_tags(&manager, m, row_tags).await);
1548 }
1549 Ok(Json(out))
1550}
1551
1552// Keeps the `MediaStatus` import used even if no handler names it.
1553#[allow(dead_code)]
1554fn _media_status_marker(_: MediaStatus) {}