Skip to main content

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) {}