Skip to main content

laterite_admin/medialib/
mod.rs

1//! The media library: a Media tab to browse, upload, arrange and remove the
2//! files the application keeps, and the route that serves them.
3//!
4//! Files live in virtual folders (rows), uploads go to the disk the
5//! application registered as `public` (else a local disk under the storage
6//! directory), and every file serves at `/storage/media/<hash>/<name>` from
7//! the hash, immutable and unguessable. A file still linked from a record is
8//! not deleted without `media.delete_linked`; the screen says who uses it.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use askama::Template;
15use axum::body::Body;
16use axum::extract::{Form, Path, Query, State};
17use axum::http::{header, HeaderMap, StatusCode};
18use axum::response::{IntoResponse, Redirect, Response};
19use axum::routing::{get, post};
20use axum::{Extension, Router};
21use laterite_auth::AuthenticatedUser;
22use laterite_core::{t, Module, Text};
23use laterite_media::library::{Listing, Sort};
24use laterite_media::preset::{self, Preset};
25use laterite_media::sanitize;
26use laterite_media::{
27    blob_path, folder, ingest, link, record, stream_of, variant, DiskReg, Kind, LocalDisk,
28    MediaRecord, NewMedia, NewMediaExtra, StorageDriver, Variants,
29};
30
31use crate::html::Markup;
32use crate::routes::{PublicRoute, PublicRouteReg, RouteCtx, Screen, ScreenReg};
33
34pub mod field;
35use crate::session::{FlashLevel, SessionHandle};
36use crate::upload::VerifiedUpload;
37use crate::{http_cache, AdminError, PageHead, Permission, Shell, ROLE_ADMIN, ROLE_EDITOR};
38
39/// See the library.
40pub const BROWSE: &str = "media.browse";
41/// Upload into it.
42pub const UPLOAD: &str = "media.upload";
43/// Rename, retitle, move, and make or remove folders.
44pub const MANAGE: &str = "media.manage";
45/// Delete a file nothing links to.
46pub const DELETE: &str = "media.delete";
47/// Delete a file records still use.
48pub const DELETE_LINKED: &str = "media.delete_linked";
49
50/// The disk library uploads go to, by name.
51pub const DISK: &str = "public";
52/// The most one upload may weigh.
53const UPLOAD_LIMIT: u64 = 256 * 1024 * 1024;
54const PER_PAGE: u64 = 60;
55
56/// The library module: its tab, its permissions and its serve route.
57pub struct MediaLibraryModule;
58
59impl Module for MediaLibraryModule {
60    fn id(&self) -> laterite_core::ModuleId {
61        laterite_core::ModuleId::new("laterite.medialibrary")
62    }
63    fn migrations(&self) -> laterite_core::MigrationSet {
64        laterite_core::MigrationSet::new("laterite.medialibrary", Vec::new())
65    }
66    fn admin_base(&self) -> Option<&'static str> {
67        Some("/")
68    }
69    // The admin adds the tab, the permissions and the serve route itself in
70    // `router()`, beside its other built-ins, so a test that builds the router
71    // directly has them too.
72}
73
74/// The library's permissions.
75pub(crate) fn permissions() -> Vec<Permission> {
76    let group = t!("Media");
77    vec![
78        Permission::new(BROWSE, t!("Browse the media library"), group.clone())
79            .roles([ROLE_ADMIN, ROLE_EDITOR]),
80        Permission::new(UPLOAD, t!("Upload media"), group.clone()).roles([ROLE_ADMIN, ROLE_EDITOR]),
81        Permission::new(MANAGE, t!("Arrange media and folders"), group.clone())
82            .roles([ROLE_ADMIN, ROLE_EDITOR]),
83        Permission::new(DELETE, t!("Delete unused media"), group.clone()).roles([ROLE_ADMIN]),
84        Permission::new(DELETE_LINKED, t!("Delete media records still use"), group)
85            .roles([ROLE_ADMIN]),
86    ]
87}
88
89/// The Media tab.
90pub(crate) fn screen() -> ScreenReg {
91    ScreenReg::new("/media", BROWSE, Arc::new(MediaScreen))
92        .in_menu(t!("Media"))
93        .menu(
94            crate::nav::Placement::new()
95                .icon("image")
96                .slot(laterite_core::arrange::slot::MEDIA),
97        )
98}
99
100/// The serve route.
101pub(crate) fn serve_route() -> PublicRouteReg {
102    PublicRouteReg::new("/storage/media", Arc::new(Serve))
103}
104
105/// The disks: each registered one by name, and the library's own.
106struct Disks {
107    by_name: HashMap<String, (Arc<dyn StorageDriver>, bool)>,
108    library: Arc<dyn StorageDriver>,
109}
110
111fn disks(ctx: &RouteCtx) -> Disks {
112    let mut by_name: HashMap<String, (Arc<dyn StorageDriver>, bool)> = ctx
113        .contributions::<DiskReg>()
114        .into_iter()
115        .map(|d| (d.name.clone(), (Arc::clone(&d.driver), d.private)))
116        .collect();
117    let library = match by_name.get(DISK) {
118        Some((driver, _)) => Arc::clone(driver),
119        None => {
120            let driver: Arc<dyn StorageDriver> =
121                Arc::new(LocalDisk::new(format!("{}/media", ctx.storage_dir())));
122            by_name.insert(DISK.to_string(), (Arc::clone(&driver), false));
123            driver
124        }
125    };
126    if !by_name.contains_key(crate::import::DISK) {
127        let driver: Arc<dyn StorageDriver> = Arc::new(LocalDisk::new(format!(
128            "{}/{}",
129            ctx.storage_dir(),
130            crate::import::DISK
131        )));
132        by_name.insert(crate::import::DISK.to_string(), (driver, true));
133    }
134    Disks { by_name, library }
135}
136
137struct AppState {
138    ctx: RouteCtx,
139    disks: Disks,
140    /// Every preset a variant may be made in, by name.
141    presets: HashMap<String, Preset>,
142    variants: Variants,
143}
144
145impl AppState {
146    fn new(ctx: &RouteCtx) -> Arc<Self> {
147        Arc::new(Self {
148            ctx: ctx.clone(),
149            disks: disks(ctx),
150            presets: preset::all(ctx.contributions::<Preset>().into_iter().cloned()),
151            variants: Variants::new(),
152        })
153    }
154}
155
156type App = State<Arc<AppState>>;
157
158/// The Media tab.
159pub struct MediaScreen;
160
161impl Screen for MediaScreen {
162    fn mount(&self, ctx: &RouteCtx) -> Router {
163        let state = AppState::new(ctx);
164        Router::new()
165            .route("/", get(index))
166            .route("/pick", get(pick))
167            .route("/resolve", get(resolve))
168            .route("/upload", post(upload))
169            .route("/direct", post(direct_start))
170            .route("/direct/complete", post(direct_complete))
171            .route("/folders", post(create_folder))
172            .route("/folders/{id}/rename", post(rename_folder))
173            .route("/folders/{id}/move", post(move_folder))
174            .route("/folders/{id}/delete", post(delete_folder))
175            .route("/{id}", get(file))
176            .route("/{id}/update", post(update_file))
177            .route("/{id}/move", post(move_file))
178            .route("/{id}/crop", post(crop_file))
179            .route("/{id}/delete", post(delete_file))
180            .with_state(state)
181    }
182}
183
184/// The public serve route: `/storage/media/<hash>/<name>`.
185pub struct Serve;
186
187impl PublicRoute for Serve {
188    fn mount(&self, ctx: &RouteCtx) -> Router {
189        let state = AppState::new(ctx);
190        Router::new()
191            .route("/{hash}/{*name}", get(serve))
192            .with_state(state)
193    }
194}
195
196/// The URL a file serves at: its hash and a display name, under the serve route.
197/// A file on a private disk needs [`signed_file_url`] instead.
198pub fn file_url(record: &MediaRecord) -> String {
199    url_for(record, None)
200}
201
202/// The URL a file's variant in `preset` serves at: the preset sits between
203/// the hash and the name. An unknown preset is a 404; a file that is not an
204/// image serves as it is.
205pub fn variant_url(record: &MediaRecord, preset: &str) -> String {
206    url_for(record, Some(preset))
207}
208
209fn url_for(record: &MediaRecord, preset: Option<&str>) -> String {
210    match preset {
211        Some(p) => format!(
212            "/storage/media/{}/{p}/{}",
213            record.hash,
214            safe_name(&record.original_name)
215        ),
216        None => format!(
217            "/storage/media/{}/{}",
218            record.hash,
219            safe_name(&record.original_name)
220        ),
221    }
222}
223
224/// How long a signed URL the admin hands out stays valid.
225pub const SIGNED_TTL: Duration = Duration::from_secs(60 * 60);
226
227/// The URL a file on a private disk serves at for `ttl`: [`file_url`] carrying
228/// an expiry and a signature over the hash and the expiry, checked by the
229/// serve route. The signature is bound to the hash, so it opens one file.
230pub fn signed_file_url(ctx: &RouteCtx, record: &MediaRecord, ttl: Duration) -> String {
231    signed_url_for(ctx, record, None, ttl)
232}
233
234/// [`variant_url`] with the signature a private disk needs; the token is the
235/// file's, so one signature opens every variant of it.
236pub fn signed_variant_url(
237    ctx: &RouteCtx,
238    record: &MediaRecord,
239    preset: &str,
240    ttl: Duration,
241) -> String {
242    signed_url_for(ctx, record, Some(preset), ttl)
243}
244
245fn signed_url_for(
246    ctx: &RouteCtx,
247    record: &MediaRecord,
248    preset: Option<&str>,
249    ttl: Duration,
250) -> String {
251    let expires = SystemTime::now()
252        .duration_since(UNIX_EPOCH)
253        .map(|d| d.as_secs())
254        .unwrap_or(0)
255        .saturating_add(ttl.as_secs());
256    let token = ctx.sign(token_message(&record.hash, expires).as_bytes());
257    format!("{}?e={expires}&t={token}", url_for(record, preset))
258}
259
260fn token_message(hash: &str, expires: u64) -> String {
261    format!("media:{hash}:{expires}")
262}
263
264/// The URL for `record` as the admin shows it: signed when its disk is private.
265fn admin_url(app: &AppState, record: &MediaRecord) -> String {
266    admin_url_for(app, record, None)
267}
268
269fn admin_url_for(app: &AppState, record: &MediaRecord, preset: Option<&str>) -> String {
270    match app.disks.by_name.get(&record.disk) {
271        Some((_, true)) => signed_url_for(&app.ctx, record, preset, SIGNED_TTL),
272        _ => url_for(record, preset),
273    }
274}
275
276/// A file name fit for a URL and a `Content-Disposition`: ASCII letters,
277/// digits, dots, hyphens and underscores, never empty.
278pub fn safe_name(name: &str) -> String {
279    let mut out: String = name
280        .chars()
281        .map(|c| {
282            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
283                c
284            } else {
285                '-'
286            }
287        })
288        .collect();
289    while out.contains("--") {
290        out = out.replace("--", "-");
291    }
292    while out.contains("-.") || out.contains(".-") {
293        out = out.replace("-.", ".").replace(".-", ".");
294    }
295    let out = out.trim_matches(|c| c == '-' || c == '.').to_string();
296    if out.is_empty() {
297        "file".to_string()
298    } else {
299        out
300    }
301}
302
303/// An upload's bytes as they may be stored: an SVG cleaned to a drawing, or
304/// nothing when it claims to be one and cannot be read; anything else as it
305/// came.
306fn admit(bytes: Vec<u8>) -> Option<Vec<u8>> {
307    if sanitize::is_svg(&bytes[..bytes.len().min(4096)]) {
308        return sanitize::svg(&bytes).map(String::into_bytes);
309    }
310    Some(bytes)
311}
312
313/// A content type the browser may run script from, served as a download.
314fn active_type(content_type: &str) -> bool {
315    matches!(
316        content_type,
317        "image/svg+xml" | "text/html" | "application/xhtml+xml" | "text/javascript"
318    )
319}
320
321#[derive(serde::Deserialize, Default)]
322struct ServeQuery {
323    /// The expiry of a signed URL, seconds since the epoch.
324    e: Option<u64>,
325    /// The signature of a signed URL.
326    t: Option<String>,
327}
328
329/// Whether `query` carries a live signature for `hash`.
330fn token_valid(ctx: &RouteCtx, hash: &str, query: &ServeQuery) -> bool {
331    let (Some(expires), Some(token)) = (query.e, query.t.as_deref()) else {
332        return false;
333    };
334    let now = SystemTime::now()
335        .duration_since(UNIX_EPOCH)
336        .map(|d| d.as_secs())
337        .unwrap_or(u64::MAX);
338    expires > now && ctx.verify_signature(token_message(hash, expires).as_bytes(), token)
339}
340
341async fn serve(
342    State(app): App,
343    headers: HeaderMap,
344    Path((hash, name)): Path<(String, String)>,
345    Query(query): Query<ServeQuery>,
346) -> Response {
347    // `<hash>/<name>` is the original; `<hash>/<preset>/<name>` a variant.
348    let preset = match name.split_once('/') {
349        Some((preset, _)) => match app.presets.get(preset) {
350            Some(p) => Some(p),
351            None => return AdminError::NotFound.into_response(),
352        },
353        None => None,
354    };
355    let records = match record::find_all_by_hash(app.ctx.db(), &hash).await {
356        Ok(r) => r,
357        Err(_) => return AdminError::NotFound.into_response(),
358    };
359    // A hash may be stored on several disks. A public one serves it to anyone,
360    // cacheable for good; a private one only behind a live signature, and then
361    // never cached.
362    let on = |private: bool| {
363        records.iter().find_map(|r| {
364            app.disks
365                .by_name
366                .get(&r.disk)
367                .filter(|(_, p)| *p == private)
368                .map(|(d, _)| (r, Arc::clone(d)))
369        })
370    };
371    let (record, driver, private) = match on(false) {
372        Some((r, d)) => (r, d, false),
373        None => match on(true) {
374            Some((r, d)) if token_valid(&app.ctx, &hash, &query) => (r, d, true),
375            _ => return AdminError::NotFound.into_response(),
376        },
377    };
378    let cache_control = if private {
379        "private, no-store".to_string()
380    } else {
381        http_cache::IMMUTABLE.to_string()
382    };
383    // A variant of an image is made on first request; any other file serves
384    // as it is under a preset too, so a template never has to ask first.
385    let variant = match preset {
386        Some(p) if record.kind() == Kind::Image && record.content_type != "image/svg+xml" => {
387            match app
388                .variants
389                .ensure(app.ctx.db(), driver.as_ref(), record, p)
390                .await
391            {
392                Ok(v) => Some(v),
393                Err(e) => {
394                    tracing::warn!(error = %e, hash = %record.hash, preset = %p.name, "a variant could not be made");
395                    return AdminError::NotFound.into_response();
396                }
397            }
398        }
399        _ => None,
400    };
401    let validator = match &variant {
402        Some(v) => format!("{}/{}", record.hash, v.preset),
403        None => record.hash.clone(),
404    };
405    let etag = format!("\"{validator}\"");
406    if !private && http_cache::matches_etag(&headers, &validator) {
407        return (
408            StatusCode::NOT_MODIFIED,
409            [(header::ETAG, etag), (header::CACHE_CONTROL, cache_control)],
410        )
411            .into_response();
412    }
413    let (path, content_type, size) = match &variant {
414        Some(v) => (v.path.clone(), v.content_type.clone(), v.size),
415        None => (
416            blob_path(&record.hash),
417            record.content_type.clone(),
418            record.size,
419        ),
420    };
421    let stream = match driver.get(&path).await {
422        Ok(s) => s,
423        Err(_) => return AdminError::NotFound.into_response(),
424    };
425    let disposition = if active_type(&content_type) || record.kind() == Kind::Other {
426        format!(
427            "attachment; filename=\"{}\"",
428            safe_name(&record.original_name)
429        )
430    } else {
431        format!("inline; filename=\"{}\"", safe_name(&record.original_name))
432    };
433    (
434        StatusCode::OK,
435        [
436            (header::CONTENT_TYPE, content_type),
437            (header::CONTENT_LENGTH, size.to_string()),
438            (header::ETAG, etag),
439            (header::CACHE_CONTROL, cache_control),
440            (header::CONTENT_DISPOSITION, disposition),
441            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
442        ],
443        Body::from_stream(stream),
444    )
445        .into_response()
446}
447
448// The picker: the library as a dialog fragment for the `media` field, and
449// the lookup its tiles are drawn from.
450
451#[derive(serde::Deserialize, Default)]
452struct PickQuery {
453    folder: Option<i64>,
454    q: Option<String>,
455    /// `image` narrows to images; anything else offers every file.
456    mode: Option<String>,
457    page: Option<u64>,
458}
459
460struct PickFile {
461    id: i64,
462    name: String,
463    kind: &'static str,
464    size: String,
465    thumb: String,
466    url: String,
467    is_image: bool,
468}
469
470#[derive(Template)]
471#[template(path = "media_pick.html")]
472struct PickTemplate {
473    shell: Shell,
474    pick_url: String,
475    mode: String,
476    q: String,
477    folder: Option<i64>,
478    crumbs: Vec<CrumbView>,
479    folders: Vec<FolderView>,
480    files: Vec<PickFile>,
481    page: u64,
482    pages: u64,
483}
484
485fn pick_href(base: &str, folder: Option<i64>, q: &str, mode: &str, page: u64) -> String {
486    let mut parts: Vec<String> = Vec::new();
487    if let Some(f) = folder {
488        parts.push(format!("folder={f}"));
489    }
490    if !q.is_empty() {
491        parts.push(format!("q={}", urlencoding(q)));
492    }
493    if !mode.is_empty() {
494        parts.push(format!("mode={mode}"));
495    }
496    if page > 1 {
497        parts.push(format!("page={page}"));
498    }
499    if parts.is_empty() {
500        base.to_string()
501    } else {
502        format!("{base}?{}", parts.join("&"))
503    }
504}
505
506async fn pick(
507    State(app): App,
508    Extension(shell): Extension<Shell>,
509    Query(query): Query<PickQuery>,
510) -> Response {
511    let ctx = &app.ctx;
512    let db = ctx.db();
513    let q = query.q.unwrap_or_default().trim().to_string();
514    let mode = query.mode.unwrap_or_default();
515    let page = query.page.unwrap_or(1).max(1);
516    let folder_id = query.folder;
517    let pick_url = ctx.url("pick");
518    let crumbs: Vec<CrumbView> = match folder_id {
519        Some(id) => folder::ancestors(db, id).await.unwrap_or_default(),
520        None => Vec::new(),
521    }
522    .into_iter()
523    .map(|f| CrumbView {
524        href: pick_href(&pick_url, Some(f.id), "", &mode, 1),
525        name: f.name,
526    })
527    .collect();
528    let folders: Vec<FolderView> = if q.is_empty() {
529        folder::children(db, folder_id)
530            .await
531            .unwrap_or_default()
532            .into_iter()
533            .map(|f| FolderView {
534                href: pick_href(&pick_url, Some(f.id), "", &mode, 1),
535                name: f.name,
536                active: false,
537            })
538            .collect()
539    } else {
540        Vec::new()
541    };
542    let listing = Listing {
543        folder: folder_id,
544        search: (!q.is_empty()).then(|| q.clone()),
545        kind: (mode == "image").then_some(Kind::Image),
546        sort: Sort::default(),
547        page,
548        per_page: PER_PAGE,
549    };
550    let listed = laterite_media::library::list(db, &listing).await.unwrap_or(
551        laterite_media::library::Page {
552            items: Vec::new(),
553            total: 0,
554        },
555    );
556    let pages = (listed.total as u64).max(1).div_ceil(PER_PAGE);
557    let files = listed
558        .items
559        .iter()
560        .map(|r| PickFile {
561            id: r.id,
562            name: r.display_name().to_string(),
563            kind: r.kind().as_str(),
564            size: human_size(r.size),
565            thumb: admin_url_for(&app, r, Some(preset::THUMB)),
566            url: admin_url(&app, r),
567            is_image: r.kind() == Kind::Image && r.content_type != "image/svg+xml",
568        })
569        .collect();
570    match Markup::from_template(&PickTemplate {
571        shell: shell.clone(),
572        pick_url,
573        mode,
574        q,
575        folder: folder_id,
576        crumbs,
577        folders,
578        files,
579        page,
580        pages,
581    }) {
582        Ok(body) => axum::response::Html(body.into_string()).into_response(),
583        Err(e) => {
584            tracing::error!(error = %e, "rendering the media picker failed");
585            AdminError::Internal(anyhow::anyhow!(e)).into_response()
586        }
587    }
588}
589
590#[derive(serde::Deserialize, Default)]
591struct ResolveQuery {
592    #[serde(default)]
593    ids: String,
594}
595
596/// The tiles for ids a field holds: name, kind, thumbnail and URL per file,
597/// in the order asked; an id that no longer exists is left out.
598async fn resolve(State(app): App, Query(query): Query<ResolveQuery>) -> Response {
599    let mut out = Vec::new();
600    for id in query
601        .ids
602        .split(',')
603        .filter_map(|p| p.trim().parse::<i64>().ok())
604        .take(64)
605    {
606        if let Ok(Some(r)) = record::find(app.ctx.db(), id).await {
607            out.push(serde_json::json!({
608                "id": r.id,
609                "name": r.display_name(),
610                "kind": r.kind().as_str(),
611                "size": human_size(r.size),
612                "thumb": admin_url_for(&app, &r, Some(preset::THUMB)),
613                "url": admin_url(&app, &r),
614                "is_image": r.kind() == Kind::Image && r.content_type != "image/svg+xml",
615            }));
616        }
617    }
618    axum::Json(out).into_response()
619}
620
621// The screen.
622
623#[derive(serde::Deserialize, Default)]
624struct BrowseQuery {
625    folder: Option<i64>,
626    q: Option<String>,
627    kind: Option<String>,
628    sort: Option<String>,
629    page: Option<u64>,
630}
631
632struct FolderView {
633    name: String,
634    href: String,
635    /// Whether the folder shown holds, or is, the one being browsed.
636    active: bool,
637}
638
639/// One entry in the browse pane: a kind or a sort order.
640struct NavLink {
641    label: String,
642    href: String,
643    icon: &'static str,
644    active: bool,
645}
646
647/// The controls beside the Media title.
648#[derive(Template)]
649#[template(path = "media_actions.html")]
650struct ActionsTemplate {
651    shell: Shell,
652    may_upload: bool,
653    may_manage: bool,
654}
655
656/// The controls beside a file's title.
657#[derive(Template)]
658#[template(path = "media_file_actions.html")]
659struct FileActionsTemplate {
660    shell: Shell,
661    url: String,
662    file_href: String,
663    may_delete: bool,
664    linked: bool,
665    /// Whether the file is an image the operator may crop.
666    may_crop: bool,
667}
668
669struct FileView {
670    name: String,
671    kind: &'static str,
672    content_type: String,
673    size: String,
674    url: String,
675    /// The `thumb` variant for the grid, the `preview` one for the file page.
676    thumb_url: String,
677    preview_url: String,
678    href: String,
679    is_image: bool,
680}
681
682struct CrumbView {
683    name: String,
684    href: String,
685}
686
687#[derive(Template)]
688#[template(path = "media.html")]
689struct IndexTemplate {
690    shell: Shell,
691    base: String,
692    /// The library's root under the current kind and sort.
693    library_href: String,
694    upload_url: String,
695    /// Where the uploader asks for a presigned PUT before sending.
696    upload_direct: String,
697    /// The fields the uploader sends before each file (`folder=3`).
698    upload_fields: String,
699    upload_hint: String,
700    folder: Option<i64>,
701    folder_name: String,
702    crumbs: Vec<CrumbView>,
703    kind_links: Vec<NavLink>,
704    sort_links: Vec<NavLink>,
705    root_folders: Vec<FolderView>,
706    folders: Vec<FolderView>,
707    files: Vec<FileView>,
708    total: i64,
709    page: u64,
710    pages: u64,
711    q: String,
712    kind: String,
713    sort: String,
714    may_upload: bool,
715    may_manage: bool,
716    prev_href: Option<String>,
717    next_href: Option<String>,
718}
719
720fn human_size(bytes: i64) -> String {
721    let b = bytes as f64;
722    if b < 1024.0 {
723        format!("{bytes} B")
724    } else if b < 1024.0 * 1024.0 {
725        format!("{:.0} KB", b / 1024.0)
726    } else if b < 1024.0 * 1024.0 * 1024.0 {
727        format!("{:.1} MB", b / 1024.0 / 1024.0)
728    } else {
729        format!("{:.2} GB", b / 1024.0 / 1024.0 / 1024.0)
730    }
731}
732
733fn kind_icon(kind: Kind) -> &'static str {
734    match kind {
735        Kind::Image => "image",
736        Kind::Video => "film",
737        Kind::Audio => "music",
738        Kind::Document => "file-text",
739        Kind::Other => "file",
740    }
741}
742
743fn kind_label(shell: &Shell, kind: Kind) -> String {
744    match kind {
745        Kind::Image => shell.t("Images"),
746        Kind::Video => shell.t("Video"),
747        Kind::Audio => shell.t("Audio"),
748        Kind::Document => shell.t("Documents"),
749        Kind::Other => shell.t("Other"),
750    }
751}
752
753fn browse_href(
754    base: &str,
755    folder: Option<i64>,
756    q: &str,
757    kind: &str,
758    sort: &str,
759    page: u64,
760) -> String {
761    let mut parts: Vec<String> = Vec::new();
762    if let Some(f) = folder {
763        parts.push(format!("folder={f}"));
764    }
765    if !q.is_empty() {
766        parts.push(format!("q={}", urlencoding(q)));
767    }
768    if !kind.is_empty() {
769        parts.push(format!("kind={kind}"));
770    }
771    if !sort.is_empty() && sort != "newest" {
772        parts.push(format!("sort={sort}"));
773    }
774    if page > 1 {
775        parts.push(format!("page={page}"));
776    }
777    if parts.is_empty() {
778        base.to_string()
779    } else {
780        format!("{base}?{}", parts.join("&"))
781    }
782}
783
784fn urlencoding(s: &str) -> String {
785    let mut out = String::new();
786    for b in s.bytes() {
787        match b {
788            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
789                out.push(b as char)
790            }
791            b' ' => out.push('+'),
792            _ => out.push_str(&format!("%{b:02X}")),
793        }
794    }
795    out
796}
797
798fn file_view(app: &AppState, r: &MediaRecord) -> FileView {
799    FileView {
800        name: r.display_name().to_string(),
801        kind: r.kind().as_str(),
802        content_type: r.content_type.clone(),
803        size: human_size(r.size),
804        url: admin_url(app, r),
805        thumb_url: admin_url_for(app, r, Some(preset::THUMB)),
806        preview_url: admin_url_for(app, r, Some(preset::PREVIEW)),
807        href: app.ctx.url(&r.id.to_string()),
808        is_image: r.kind() == Kind::Image && r.content_type != "image/svg+xml",
809    }
810}
811
812fn page_with<T: Template>(shell: &Shell, head: PageHead, template: &T) -> Response {
813    match Markup::from_template(template) {
814        Ok(body) => shell.page_with(head, body),
815        Err(e) => {
816            tracing::error!(error = %e, "rendering a media page failed");
817            AdminError::Internal(anyhow::anyhow!(e)).into_response()
818        }
819    }
820}
821
822async fn index(
823    State(app): App,
824    Extension(shell): Extension<Shell>,
825    Extension(user): Extension<AuthenticatedUser>,
826    Query(query): Query<BrowseQuery>,
827) -> Response {
828    let ctx = &app.ctx;
829    let db = ctx.db();
830    let q = query.q.unwrap_or_default().trim().to_string();
831    let kind_name = query.kind.unwrap_or_default();
832    let kind = Kind::parse(&kind_name);
833    let sort_name = query.sort.unwrap_or_default();
834    let sort = Sort::parse(&sort_name).unwrap_or_default();
835    let page = query.page.unwrap_or(1).max(1);
836    let folder_id = query.folder;
837    let base = ctx.url("");
838
839    let ancestors = match folder_id {
840        Some(id) => folder::ancestors(db, id).await.unwrap_or_default(),
841        None => Vec::new(),
842    };
843    let folder_name = ancestors.last().map(|f| f.name.clone()).unwrap_or_default();
844    let top = ancestors.first().map(|f| f.id);
845    let crumbs: Vec<CrumbView> = ancestors
846        .iter()
847        .map(|f| CrumbView {
848            name: f.name.clone(),
849            href: browse_href(&base, Some(f.id), "", &kind_name, &sort_name, 1),
850        })
851        .collect();
852    let folders: Vec<FolderView> = if q.is_empty() {
853        folder::children(db, folder_id)
854            .await
855            .unwrap_or_default()
856            .into_iter()
857            .map(|f| FolderView {
858                href: browse_href(&base, Some(f.id), "", &kind_name, &sort_name, 1),
859                name: f.name,
860                active: false,
861            })
862            .collect()
863    } else {
864        Vec::new()
865    };
866    let root_folders: Vec<FolderView> = folder::children(db, None)
867        .await
868        .unwrap_or_default()
869        .into_iter()
870        .map(|f| FolderView {
871            href: browse_href(&base, Some(f.id), "", &kind_name, &sort_name, 1),
872            active: top == Some(f.id),
873            name: f.name,
874        })
875        .collect();
876    let kind_links = std::iter::once(NavLink {
877        label: shell.t("Everything"),
878        href: browse_href(&base, folder_id, &q, "", &sort_name, 1),
879        icon: "layers",
880        active: kind.is_none(),
881    })
882    .chain(Kind::ALL.into_iter().map(|k| NavLink {
883        label: kind_label(&shell, k),
884        href: browse_href(&base, folder_id, &q, k.as_str(), &sort_name, 1),
885        icon: kind_icon(k),
886        active: kind == Some(k),
887    }))
888    .collect();
889    let sort_links = [
890        ("newest", shell.t("Newest first")),
891        ("oldest", shell.t("Oldest first")),
892        ("name", shell.t("Name")),
893        ("size", shell.t("Largest first")),
894    ]
895    .into_iter()
896    .map(|(code, label)| NavLink {
897        label,
898        href: browse_href(&base, folder_id, &q, &kind_name, code, 1),
899        icon: "",
900        active: Sort::parse(code).unwrap_or_default() == sort,
901    })
902    .collect();
903    let listing = Listing {
904        folder: folder_id,
905        search: (!q.is_empty()).then(|| q.clone()),
906        kind,
907        sort,
908        page,
909        per_page: PER_PAGE,
910    };
911    let listed = laterite_media::library::list(db, &listing).await.unwrap_or(
912        laterite_media::library::Page {
913            items: Vec::new(),
914            total: 0,
915        },
916    );
917    let pages = (listed.total as u64).max(1).div_ceil(PER_PAGE);
918    let files = listed.items.iter().map(|r| file_view(&app, r)).collect();
919    let may_upload = user.permissions.allows(UPLOAD);
920    let may_manage = user.permissions.allows(MANAGE);
921    let actions = Markup::from_template(&ActionsTemplate {
922        shell: shell.clone(),
923        may_upload,
924        may_manage,
925    })
926    .unwrap_or_else(|_| Markup::empty());
927    page_with(
928        &shell,
929        PageHead::new(t!("Media")).actions(actions),
930        &IndexTemplate {
931            library_href: browse_href(&base, None, "", &kind_name, &sort_name, 1),
932            upload_url: format!("{base}/upload"),
933            upload_direct: format!("{base}/direct"),
934            upload_fields: folder_id.map(|f| format!("folder={f}")).unwrap_or_default(),
935            upload_hint: if crumbs.is_empty() {
936                shell.t("Into the library. Identical files are stored once.")
937            } else {
938                shell.t("Into this folder. Identical files are stored once.")
939            },
940            folder_name,
941            kind_links,
942            sort_links,
943            root_folders,
944            prev_href: (page > 1)
945                .then(|| browse_href(&base, folder_id, &q, &kind_name, &sort_name, page - 1)),
946            next_href: (page < pages)
947                .then(|| browse_href(&base, folder_id, &q, &kind_name, &sort_name, page + 1)),
948            may_upload,
949            may_manage,
950            shell: shell.clone(),
951            base,
952            folder: folder_id,
953            crumbs,
954            folders,
955            files,
956            total: listed.total,
957            page,
958            pages,
959            q,
960            kind: kind_name,
961            sort: sort.as_str().to_string(),
962        },
963    )
964}
965
966fn back_to(ctx: &RouteCtx, folder: Option<i64>) -> Response {
967    let base = ctx.url("");
968    Redirect::to(&browse_href(&base, folder, "", "", "", 1)).into_response()
969}
970
971fn forbidden() -> Response {
972    AdminError::Forbidden.into_response()
973}
974
975/// One file the uploader stored, as its JSON answer names it.
976#[derive(serde::Serialize)]
977struct StoredFile {
978    id: i64,
979    name: String,
980    href: String,
981    url: String,
982}
983
984/// One file the uploader refused, and why.
985#[derive(serde::Serialize)]
986struct RefusedFile {
987    name: String,
988    reason: String,
989}
990
991async fn upload(
992    State(app): App,
993    Extension(shell): Extension<Shell>,
994    Extension(session): Extension<SessionHandle>,
995    Extension(user): Extension<AuthenticatedUser>,
996    headers: HeaderMap,
997    mut upload: VerifiedUpload,
998) -> Response {
999    if !user.permissions.allows(UPLOAD) {
1000        return forbidden();
1001    }
1002    // The uploader widget sends one file per request and reads the answer;
1003    // a plain form submit is redirected back to the folder with a flash.
1004    let from_script = headers
1005        .get("x-requested-with")
1006        .and_then(|v| v.to_str().ok())
1007        .is_some_and(|v| v == "fetch");
1008    let ctx = &app.ctx;
1009    let mut folder_id: Option<i64> = None;
1010    let mut stored: Vec<StoredFile> = Vec::new();
1011    let mut refused: Vec<RefusedFile> = Vec::new();
1012    let refuse = |name: String, reason: Text| RefusedFile {
1013        name,
1014        reason: shell.tt(&reason),
1015    };
1016    while let Ok(Some(mut field)) = upload.next_field().await {
1017        match field.name().unwrap_or_default() {
1018            "folder" => {
1019                folder_id = field
1020                    .text()
1021                    .await
1022                    .ok()
1023                    .and_then(|t| t.trim().parse::<i64>().ok());
1024            }
1025            "file" => {
1026                let name = field
1027                    .file_name()
1028                    .map(|n| n.to_string())
1029                    .filter(|n| !n.is_empty())
1030                    .unwrap_or_else(|| "upload".to_string());
1031                let mut bytes: Vec<u8> = Vec::new();
1032                let mut too_large = false;
1033                while let Ok(Some(chunk)) = field.chunk().await {
1034                    if bytes.len() as u64 + chunk.len() as u64 > UPLOAD_LIMIT {
1035                        too_large = true;
1036                        break;
1037                    }
1038                    bytes.extend_from_slice(&chunk);
1039                }
1040                if too_large {
1041                    refused.push(refuse(name, t!("Larger than the upload limit.")));
1042                    continue;
1043                }
1044                if bytes.is_empty() {
1045                    continue;
1046                }
1047                if !sanitize::extension_allowed(&name, ctx.upload_extensions()) {
1048                    refused.push(refuse(name, t!("This file type is not allowed.")));
1049                    continue;
1050                }
1051                let bytes = match admit(bytes) {
1052                    Some(bytes) => bytes,
1053                    None => {
1054                        refused.push(refuse(name, t!("The image could not be read.")));
1055                        continue;
1056                    }
1057                };
1058                // Metadata goes before the hash, so two copies of one photo
1059                // with different EXIF still share a blob.
1060                let prepared = laterite_media::image::prepare(bytes::Bytes::from(bytes));
1061                let ingested = match ingest(
1062                    app.disks.library.as_ref(),
1063                    stream_of(prepared.bytes.clone()),
1064                    UPLOAD_LIMIT,
1065                )
1066                .await
1067                {
1068                    Ok(i) => i,
1069                    Err(e) => {
1070                        tracing::error!(error = %e, "a library upload could not be stored");
1071                        refused.push(refuse(name, t!("Could not be stored.")));
1072                        continue;
1073                    }
1074                };
1075                let id = match record::create_with(
1076                    ctx.db(),
1077                    NewMedia {
1078                        disk: DISK.to_string(),
1079                        hash: ingested.hash,
1080                        size: ingested.size as i64,
1081                        content_type: ingested.content_type,
1082                        original_name: name.clone(),
1083                        in_library: true,
1084                        created_by: Some(user.user.id),
1085                    },
1086                    NewMediaExtra {
1087                        folder_id,
1088                        width: prepared.width.map(i64::from),
1089                        height: prepared.height.map(i64::from),
1090                        meta: (prepared.orientation != 1)
1091                            .then(|| serde_json::json!({ "orientation": prepared.orientation })),
1092                    },
1093                )
1094                .await
1095                {
1096                    Ok(id) => id,
1097                    Err(e) => {
1098                        tracing::error!(error = %e, "a library record could not be written");
1099                        refused.push(refuse(name, t!("Could not be recorded.")));
1100                        continue;
1101                    }
1102                };
1103                ctx.audit(
1104                    &user,
1105                    "media.upload",
1106                    Some("media"),
1107                    Some(&id.to_string()),
1108                    Some(&name),
1109                    None,
1110                )
1111                .await;
1112                let url = match record::find(ctx.db(), id).await {
1113                    Ok(Some(r)) => admin_url(&app, &r),
1114                    _ => String::new(),
1115                };
1116                stored.push(StoredFile {
1117                    id,
1118                    name,
1119                    href: ctx.url(&id.to_string()),
1120                    url,
1121                });
1122            }
1123            _ => {}
1124        }
1125    }
1126    if from_script {
1127        return axum::Json(serde_json::json!({ "stored": stored, "refused": refused }))
1128            .into_response();
1129    }
1130    if !stored.is_empty() {
1131        session.push_flash(
1132            FlashLevel::Success,
1133            t!("Uploaded {n} file(s).", n = stored.len() as i64),
1134        );
1135    }
1136    if !refused.is_empty() {
1137        let names: Vec<&str> = refused.iter().map(|r| r.name.as_str()).collect();
1138        session.push_flash(
1139            FlashLevel::Error,
1140            Text::dynamic(format!(
1141                "Could not store: {} (too large or unreadable).",
1142                names.join(", ")
1143            )),
1144        );
1145    }
1146    if stored.is_empty() && refused.is_empty() {
1147        session.push_flash(FlashLevel::Error, t!("Choose a file to upload."));
1148    }
1149    back_to(ctx, folder_id)
1150}
1151
1152/// How long a browser has to finish a direct upload once it asked.
1153const DIRECT_TTL: Duration = Duration::from_secs(60 * 60);
1154
1155#[derive(serde::Deserialize)]
1156struct DirectStartForm {
1157    #[serde(default)]
1158    name: String,
1159    #[serde(default)]
1160    content_type: String,
1161    #[serde(default)]
1162    size: u64,
1163}
1164
1165/// The uploader asks before sending: a disk that presigns answers with a PUT
1166/// URL under a quarantine prefix the server chose; any other answers that the
1167/// file goes through the application.
1168async fn direct_start(
1169    State(app): App,
1170    Extension(user): Extension<AuthenticatedUser>,
1171    Form(form): Form<DirectStartForm>,
1172) -> Response {
1173    if !user.permissions.allows(UPLOAD) {
1174        return forbidden();
1175    }
1176    if form.size > UPLOAD_LIMIT {
1177        return axum::Json(serde_json::json!({ "direct": false, "reason": "too large" }))
1178            .into_response();
1179    }
1180    if !sanitize::extension_allowed(&form.name, app.ctx.upload_extensions()) {
1181        return axum::Json(serde_json::json!({ "direct": false, "reason": "not allowed" }))
1182            .into_response();
1183    }
1184    let content_type = if form.content_type.is_empty() {
1185        "application/octet-stream".to_string()
1186    } else {
1187        form.content_type.chars().take(120).collect()
1188    };
1189    let path = format!("quarantine/{}/{}", quarantine_id(), safe_name(&form.name));
1190    match app
1191        .disks
1192        .library
1193        .presigned_put(&path, &content_type, DIRECT_TTL)
1194        .await
1195    {
1196        Ok(Some(url)) => axum::Json(serde_json::json!({
1197            "direct": true,
1198            "url": url,
1199            "path": path,
1200            "headers": { "Content-Type": content_type },
1201        }))
1202        .into_response(),
1203        Ok(None) => axum::Json(serde_json::json!({ "direct": false })).into_response(),
1204        Err(e) => {
1205            tracing::warn!(error = %e, "a direct upload could not be presigned");
1206            axum::Json(serde_json::json!({ "direct": false })).into_response()
1207        }
1208    }
1209}
1210
1211/// Thirty-two hex characters nobody can guess, from the signing key's source
1212/// of randomness.
1213fn quarantine_id() -> String {
1214    use rand::RngCore;
1215    let mut bytes = [0u8; 16];
1216    rand::thread_rng().fill_bytes(&mut bytes);
1217    bytes.iter().map(|b| format!("{b:02x}")).collect()
1218}
1219
1220#[derive(serde::Deserialize)]
1221struct DirectCompleteForm {
1222    #[serde(default)]
1223    path: String,
1224    #[serde(default)]
1225    name: String,
1226    #[serde(default)]
1227    folder: String,
1228}
1229
1230/// The browser finished its PUT: the server reads the quarantined object,
1231/// sniffs and hashes it, strips an image's metadata, stores it under its hash,
1232/// records it and removes the quarantine copy. The client never names a blob
1233/// path; it only ever hands back the quarantine path it was given.
1234async fn direct_complete(
1235    State(app): App,
1236    Extension(shell): Extension<Shell>,
1237    Extension(user): Extension<AuthenticatedUser>,
1238    Form(form): Form<DirectCompleteForm>,
1239) -> Response {
1240    if !user.permissions.allows(UPLOAD) {
1241        return forbidden();
1242    }
1243    let ctx = &app.ctx;
1244    let refused = |reason: Text| {
1245        axum::Json(serde_json::json!({
1246            "stored": [],
1247            "refused": [{ "name": form.name.clone(), "reason": shell.tt(&reason) }],
1248        }))
1249        .into_response()
1250    };
1251    let path = form.path.trim();
1252    let well_formed = path.starts_with("quarantine/")
1253        && path.len() < 300
1254        && path
1255            .chars()
1256            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_'))
1257        && !path.contains("..");
1258    if !well_formed {
1259        return refused(t!("The upload could not be found."));
1260    }
1261    let disk = Arc::clone(&app.disks.library);
1262    let size = match disk.metadata(path).await {
1263        Ok(Some(meta)) => meta.size,
1264        _ => return refused(t!("The upload could not be found.")),
1265    };
1266    if size == 0 || size > UPLOAD_LIMIT {
1267        let _ = disk.delete(path).await;
1268        return refused(t!("Larger than the upload limit."));
1269    }
1270    let mut stream = match disk.get(path).await {
1271        Ok(s) => s,
1272        Err(_) => return refused(t!("The upload could not be found.")),
1273    };
1274    use futures::TryStreamExt;
1275    let mut bytes: Vec<u8> = Vec::with_capacity(usize::try_from(size).unwrap_or(0));
1276    while let Ok(Some(chunk)) = stream.try_next().await {
1277        bytes.extend_from_slice(&chunk);
1278    }
1279    if !sanitize::extension_allowed(&form.name, ctx.upload_extensions()) {
1280        let _ = disk.delete(path).await;
1281        return refused(t!("This file type is not allowed."));
1282    }
1283    let Some(bytes) = admit(bytes) else {
1284        let _ = disk.delete(path).await;
1285        return refused(t!("The image could not be read."));
1286    };
1287    let prepared = laterite_media::image::prepare(bytes::Bytes::from(bytes));
1288    let ingested = match ingest(
1289        disk.as_ref(),
1290        stream_of(prepared.bytes.clone()),
1291        UPLOAD_LIMIT,
1292    )
1293    .await
1294    {
1295        Ok(i) => i,
1296        Err(e) => {
1297            tracing::error!(error = %e, "a direct upload could not be stored");
1298            return refused(t!("Could not be stored."));
1299        }
1300    };
1301    let _ = disk.delete(path).await;
1302    let folder_id = folder_param(&form.folder);
1303    let name = if form.name.trim().is_empty() {
1304        "upload".to_string()
1305    } else {
1306        form.name.trim().to_string()
1307    };
1308    let id = match record::create_with(
1309        ctx.db(),
1310        NewMedia {
1311            disk: DISK.to_string(),
1312            hash: ingested.hash,
1313            size: ingested.size as i64,
1314            content_type: ingested.content_type,
1315            original_name: name.clone(),
1316            in_library: true,
1317            created_by: Some(user.user.id),
1318        },
1319        NewMediaExtra {
1320            folder_id,
1321            width: prepared.width.map(i64::from),
1322            height: prepared.height.map(i64::from),
1323            meta: (prepared.orientation != 1)
1324                .then(|| serde_json::json!({ "orientation": prepared.orientation })),
1325        },
1326    )
1327    .await
1328    {
1329        Ok(id) => id,
1330        Err(e) => {
1331            tracing::error!(error = %e, "a direct upload could not be recorded");
1332            return refused(t!("Could not be recorded."));
1333        }
1334    };
1335    ctx.audit(
1336        &user,
1337        "media.upload",
1338        Some("media"),
1339        Some(&id.to_string()),
1340        Some(&name),
1341        Some("{\"direct\":true}"),
1342    )
1343    .await;
1344    let url = match record::find(ctx.db(), id).await {
1345        Ok(Some(r)) => admin_url(&app, &r),
1346        _ => String::new(),
1347    };
1348    axum::Json(serde_json::json!({
1349        "stored": [{ "id": id, "name": name, "href": ctx.url(&id.to_string()), "url": url }],
1350        "refused": [],
1351    }))
1352    .into_response()
1353}
1354
1355/// An optional folder id as a form sends it: blank for the root.
1356fn folder_param(raw: &str) -> Option<i64> {
1357    raw.trim().parse::<i64>().ok()
1358}
1359
1360#[derive(serde::Deserialize)]
1361struct FolderForm {
1362    #[serde(default)]
1363    parent: String,
1364    #[serde(default)]
1365    name: String,
1366}
1367
1368fn flash_folder_error(session: &SessionHandle, e: &laterite_media::FolderError) {
1369    session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
1370}
1371
1372async fn create_folder(
1373    State(app): App,
1374    Extension(session): Extension<SessionHandle>,
1375    Extension(user): Extension<AuthenticatedUser>,
1376    Form(form): Form<FolderForm>,
1377) -> Response {
1378    if !user.permissions.allows(MANAGE) {
1379        return forbidden();
1380    }
1381    let ctx = &app.ctx;
1382    let parent = folder_param(&form.parent);
1383    match folder::create(ctx.db(), parent, &form.name).await {
1384        Ok(f) => {
1385            ctx.audit(
1386                &user,
1387                "media.folder.create",
1388                Some("media_folder"),
1389                Some(&f.id.to_string()),
1390                Some(&f.path),
1391                None,
1392            )
1393            .await;
1394            session.push_flash(FlashLevel::Success, t!("Folder created."));
1395            back_to(ctx, Some(f.id))
1396        }
1397        Err(e) => {
1398            flash_folder_error(&session, &e);
1399            back_to(ctx, parent)
1400        }
1401    }
1402}
1403
1404#[derive(serde::Deserialize)]
1405struct NameForm {
1406    name: String,
1407}
1408
1409async fn rename_folder(
1410    State(app): App,
1411    Extension(session): Extension<SessionHandle>,
1412    Extension(user): Extension<AuthenticatedUser>,
1413    Path(id): Path<i64>,
1414    Form(form): Form<NameForm>,
1415) -> Response {
1416    if !user.permissions.allows(MANAGE) {
1417        return forbidden();
1418    }
1419    let ctx = &app.ctx;
1420    match folder::rename(ctx.db(), id, &form.name).await {
1421        Ok(f) => {
1422            ctx.audit(
1423                &user,
1424                "media.folder.rename",
1425                Some("media_folder"),
1426                Some(&id.to_string()),
1427                Some(&f.path),
1428                None,
1429            )
1430            .await;
1431            session.push_flash(FlashLevel::Success, t!("Folder renamed."));
1432            back_to(ctx, f.parent_id)
1433        }
1434        Err(e) => {
1435            flash_folder_error(&session, &e);
1436            back_to(ctx, Some(id))
1437        }
1438    }
1439}
1440
1441#[derive(serde::Deserialize)]
1442struct MoveForm {
1443    #[serde(default)]
1444    to: String,
1445}
1446
1447async fn move_folder(
1448    State(app): App,
1449    Extension(session): Extension<SessionHandle>,
1450    Extension(user): Extension<AuthenticatedUser>,
1451    Path(id): Path<i64>,
1452    Form(form): Form<MoveForm>,
1453) -> Response {
1454    if !user.permissions.allows(MANAGE) {
1455        return forbidden();
1456    }
1457    let ctx = &app.ctx;
1458    let to = folder_param(&form.to);
1459    match folder::move_to(ctx.db(), id, to).await {
1460        Ok(f) => {
1461            ctx.audit(
1462                &user,
1463                "media.folder.move",
1464                Some("media_folder"),
1465                Some(&id.to_string()),
1466                Some(&f.path),
1467                None,
1468            )
1469            .await;
1470            session.push_flash(FlashLevel::Success, t!("Folder moved."));
1471            back_to(ctx, to)
1472        }
1473        Err(e) => {
1474            flash_folder_error(&session, &e);
1475            back_to(ctx, Some(id))
1476        }
1477    }
1478}
1479
1480async fn delete_folder(
1481    State(app): App,
1482    Extension(session): Extension<SessionHandle>,
1483    Extension(user): Extension<AuthenticatedUser>,
1484    Path(id): Path<i64>,
1485) -> Response {
1486    if !user.permissions.allows(MANAGE) {
1487        return forbidden();
1488    }
1489    let ctx = &app.ctx;
1490    let parent = folder::find(ctx.db(), id)
1491        .await
1492        .ok()
1493        .flatten()
1494        .and_then(|f| f.parent_id);
1495    match folder::delete(ctx.db(), id).await {
1496        Ok(()) => {
1497            ctx.audit(
1498                &user,
1499                "media.folder.delete",
1500                Some("media_folder"),
1501                Some(&id.to_string()),
1502                None,
1503                None,
1504            )
1505            .await;
1506            session.push_flash(FlashLevel::Success, t!("Folder deleted."));
1507            back_to(ctx, parent)
1508        }
1509        Err(e) => {
1510            flash_folder_error(&session, &e);
1511            back_to(ctx, Some(id))
1512        }
1513    }
1514}
1515
1516struct UserView {
1517    owner_type: String,
1518    owner_id: String,
1519    field: String,
1520}
1521
1522struct FolderChoice {
1523    id: String,
1524    path: String,
1525}
1526
1527#[derive(Template)]
1528#[template(path = "media_file.html")]
1529struct FileTemplate {
1530    shell: Shell,
1531    file_href: String,
1532    /// The image's size, for the crop dialog; zero for anything else.
1533    width: i64,
1534    height: i64,
1535    may_crop: bool,
1536    file: FileView,
1537    original_name: String,
1538    title: String,
1539    alt: String,
1540    dimensions: String,
1541    uploaded: String,
1542    disk: String,
1543    hash: String,
1544    users: Vec<UserView>,
1545    folders: Vec<FolderChoice>,
1546    folder: String,
1547    may_manage: bool,
1548}
1549
1550async fn load(ctx: &RouteCtx, id: i64) -> Option<MediaRecord> {
1551    record::find(ctx.db(), id).await.ok().flatten()
1552}
1553
1554async fn file(
1555    State(app): App,
1556    Extension(shell): Extension<Shell>,
1557    Extension(user): Extension<AuthenticatedUser>,
1558    Path(id): Path<i64>,
1559) -> Response {
1560    let ctx = &app.ctx;
1561    let Some(mut r) = load(ctx, id).await else {
1562        return AdminError::NotFound.into_response();
1563    };
1564    // An image stored before sizes were recorded is measured the first time
1565    // its page opens, so the crop dialog and the templates have its size.
1566    if r.width.is_none() && r.kind() == Kind::Image && r.content_type != "image/svg+xml" {
1567        let measured = measure(&app, &r).await;
1568        if let Some((w, h)) = measured {
1569            let _ = record::set_dimensions(ctx.db(), id, Some(w), Some(h)).await;
1570            r.width = Some(w);
1571            r.height = Some(h);
1572        }
1573    }
1574    let users: Vec<UserView> = link::users_of(ctx.db(), id)
1575        .await
1576        .unwrap_or_default()
1577        .into_iter()
1578        .map(|l| UserView {
1579            owner_type: l.owner_type,
1580            owner_id: l.owner_id,
1581            field: l.field,
1582        })
1583        .collect();
1584    let folders: Vec<FolderChoice> = folder::all(ctx.db())
1585        .await
1586        .unwrap_or_default()
1587        .into_iter()
1588        .map(|f| FolderChoice {
1589            id: f.id.to_string(),
1590            path: f.path,
1591        })
1592        .collect();
1593    let dimensions = match (r.width, r.height) {
1594        (Some(w), Some(h)) => format!("{w} × {h}"),
1595        _ => String::new(),
1596    };
1597    let view = file_view(&app, &r);
1598    let folder_label = ancestors_name(ctx, r.folder_id).await;
1599    let linked = !users.is_empty();
1600    let may_delete = if linked {
1601        user.permissions.allows(DELETE_LINKED)
1602    } else {
1603        user.permissions.allows(DELETE)
1604    };
1605    let may_crop = user.permissions.allows(MANAGE) && view.is_image;
1606    let actions = Markup::from_template(&FileActionsTemplate {
1607        shell: shell.clone(),
1608        url: view.url.clone(),
1609        file_href: ctx.url(&id.to_string()),
1610        may_delete,
1611        linked,
1612        may_crop,
1613    })
1614    .unwrap_or_else(|_| Markup::empty());
1615    page_with(
1616        &shell,
1617        PageHead::new(Text::dynamic(r.display_name().to_string()))
1618            .back(
1619                if folder_label.is_empty() {
1620                    t!("Media")
1621                } else {
1622                    Text::dynamic(folder_label)
1623                },
1624                browse_href(&ctx.url(""), r.folder_id, "", "", "", 1),
1625            )
1626            .actions(actions),
1627        &FileTemplate {
1628            file_href: ctx.url(&id.to_string()),
1629            width: r.width.unwrap_or(0),
1630            height: r.height.unwrap_or(0),
1631            may_crop,
1632            file: view,
1633            original_name: r.original_name.clone(),
1634            title: r.title.clone().unwrap_or_default(),
1635            alt: r.alt.clone().unwrap_or_default(),
1636            dimensions,
1637            uploaded: shell.datetime(&r.created_at),
1638            disk: r.disk.clone(),
1639            hash: r.hash.clone(),
1640            users,
1641            folders,
1642            folder: r.folder_id.map(|f| f.to_string()).unwrap_or_default(),
1643            may_manage: user.permissions.allows(MANAGE),
1644            shell: shell.clone(),
1645        },
1646    )
1647}
1648
1649/// An image's size read from its blob's header.
1650async fn measure(app: &AppState, r: &MediaRecord) -> Option<(i64, i64)> {
1651    use futures::TryStreamExt;
1652    let driver = Arc::clone(&app.disks.by_name.get(&r.disk)?.0);
1653    let path = blob_path(&r.hash);
1654    let mut stream = driver.get(&path).await.ok()?;
1655    let mut bytes: Vec<u8> = Vec::new();
1656    while let Ok(Some(chunk)) = stream.try_next().await {
1657        bytes.extend_from_slice(&chunk);
1658        // Every header this reader understands sits in the first kilobytes,
1659        // except a JPEG whose metadata comes first; a megabyte covers both.
1660        if bytes.len() > 1024 * 1024 {
1661            break;
1662        }
1663    }
1664    let (w, h) = laterite_media::image::header_dimensions(&bytes)?;
1665    Some((i64::from(w), i64::from(h)))
1666}
1667
1668/// The name of the folder a file sits in, for the way back; empty at the root.
1669async fn ancestors_name(ctx: &RouteCtx, folder_id: Option<i64>) -> String {
1670    match folder_id {
1671        Some(id) => folder::find(ctx.db(), id)
1672            .await
1673            .ok()
1674            .flatten()
1675            .map(|f| f.name)
1676            .unwrap_or_default(),
1677        None => String::new(),
1678    }
1679}
1680
1681#[derive(serde::Deserialize)]
1682struct CropForm {
1683    #[serde(default)]
1684    x: u32,
1685    #[serde(default)]
1686    y: u32,
1687    #[serde(default)]
1688    w: u32,
1689    #[serde(default)]
1690    h: u32,
1691    /// `new` (the default) stores the crop as another file beside the
1692    /// original; `replace` rewrites this file's bytes.
1693    #[serde(default)]
1694    mode: String,
1695}
1696
1697/// Crops an image to the rectangle the operator drew, as a new file in the
1698/// same folder or in place of the original.
1699async fn crop_file(
1700    State(app): App,
1701    Extension(session): Extension<SessionHandle>,
1702    Extension(user): Extension<AuthenticatedUser>,
1703    Path(id): Path<i64>,
1704    Form(form): Form<CropForm>,
1705) -> Response {
1706    if !user.permissions.allows(MANAGE) {
1707        return forbidden();
1708    }
1709    let ctx = &app.ctx;
1710    let here = ctx.url(&id.to_string());
1711    let Some(r) = load(ctx, id).await else {
1712        return AdminError::NotFound.into_response();
1713    };
1714    if r.kind() != Kind::Image || r.content_type == "image/svg+xml" {
1715        session.push_flash(FlashLevel::Error, t!("Only an image can be cropped."));
1716        return Redirect::to(&here).into_response();
1717    }
1718    if form.w == 0 || form.h == 0 {
1719        session.push_flash(FlashLevel::Error, t!("Draw the area to keep first."));
1720        return Redirect::to(&here).into_response();
1721    }
1722    let Some((driver, _)) = app.disks.by_name.get(&r.disk) else {
1723        session.push_flash(FlashLevel::Error, t!("This file's disk is not registered."));
1724        return Redirect::to(&here).into_response();
1725    };
1726    let mut bytes: Vec<u8> = Vec::with_capacity(usize::try_from(r.size).unwrap_or(0));
1727    let mut stream = match driver.get(&blob_path(&r.hash)).await {
1728        Ok(s) => s,
1729        Err(_) => return AdminError::NotFound.into_response(),
1730    };
1731    use futures::TryStreamExt;
1732    while let Ok(Some(chunk)) = stream.try_next().await {
1733        bytes.extend_from_slice(&chunk);
1734    }
1735    let orientation = r
1736        .meta
1737        .get("orientation")
1738        .and_then(|o| o.as_u64())
1739        .and_then(|o| u8::try_from(o).ok())
1740        .unwrap_or(1);
1741    let rect = laterite_media::image::Rect {
1742        x: form.x,
1743        y: form.y,
1744        width: form.w,
1745        height: form.h,
1746    };
1747    let rendered = match tokio::task::spawn_blocking(move || {
1748        laterite_media::image::crop(&bytes, rect, orientation)
1749    })
1750    .await
1751    {
1752        Ok(Ok(out)) => out,
1753        Ok(Err(e)) => {
1754            session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
1755            return Redirect::to(&here).into_response();
1756        }
1757        Err(e) => {
1758            tracing::error!(error = %e, "a crop task failed");
1759            return AdminError::Internal(anyhow::anyhow!(e)).into_response();
1760        }
1761    };
1762    let ingested = match ingest(
1763        driver.as_ref(),
1764        stream_of(rendered.bytes.clone()),
1765        UPLOAD_LIMIT,
1766    )
1767    .await
1768    {
1769        Ok(i) => i,
1770        Err(e) => {
1771            tracing::error!(error = %e, "a crop could not be stored");
1772            session.push_flash(FlashLevel::Error, t!("The crop could not be stored."));
1773            return Redirect::to(&here).into_response();
1774        }
1775    };
1776    let (width, height) = (
1777        Some(i64::from(rendered.width)),
1778        Some(i64::from(rendered.height)),
1779    );
1780    if form.mode == "replace" {
1781        let variants = variant::list_for(ctx.db(), id).await.unwrap_or_default();
1782        let _ = variant::delete_for(ctx.db(), id).await;
1783        for v in &variants {
1784            let _ = driver.delete(&v.path).await;
1785        }
1786        let old_hash = r.hash.clone();
1787        if let Err(e) = record::replace_blob(
1788            ctx.db(),
1789            id,
1790            &ingested.hash,
1791            ingested.size as i64,
1792            &ingested.content_type,
1793            width,
1794            height,
1795        )
1796        .await
1797        {
1798            tracing::error!(error = %e, "a crop could not be recorded");
1799            return AdminError::Internal(anyhow::anyhow!(e)).into_response();
1800        }
1801        // The old bytes go when this was their last record.
1802        let still_used = record::find_all_by_hash(ctx.db(), &old_hash)
1803            .await
1804            .map(|rs| rs.iter().any(|x| x.disk == r.disk))
1805            .unwrap_or(true);
1806        if !still_used && old_hash != ingested.hash {
1807            let _ = driver.delete(&blob_path(&old_hash)).await;
1808        }
1809        ctx.audit(
1810            &user,
1811            "media.crop",
1812            Some("media"),
1813            Some(&id.to_string()),
1814            Some(&r.original_name),
1815            Some(&format!(
1816                "{{\"x\":{},\"y\":{},\"w\":{},\"h\":{},\"replaced\":true}}",
1817                form.x, form.y, form.w, form.h
1818            )),
1819        )
1820        .await;
1821        session.push_flash(FlashLevel::Success, t!("Image cropped."));
1822        return Redirect::to(&here).into_response();
1823    }
1824    let name = cropped_name(&r.original_name, rendered.extension);
1825    let new_id = match record::create_with(
1826        ctx.db(),
1827        NewMedia {
1828            disk: r.disk.clone(),
1829            hash: ingested.hash,
1830            size: ingested.size as i64,
1831            content_type: ingested.content_type,
1832            original_name: name.clone(),
1833            in_library: true,
1834            created_by: Some(user.user.id),
1835        },
1836        NewMediaExtra {
1837            folder_id: r.folder_id,
1838            width,
1839            height,
1840            meta: None,
1841        },
1842    )
1843    .await
1844    {
1845        Ok(id) => id,
1846        Err(e) => {
1847            tracing::error!(error = %e, "a crop could not be recorded");
1848            return AdminError::Internal(anyhow::anyhow!(e)).into_response();
1849        }
1850    };
1851    ctx.audit(
1852        &user,
1853        "media.crop",
1854        Some("media"),
1855        Some(&new_id.to_string()),
1856        Some(&name),
1857        Some(&format!(
1858            "{{\"from\":{id},\"x\":{},\"y\":{},\"w\":{},\"h\":{}}}",
1859            form.x, form.y, form.w, form.h
1860        )),
1861    )
1862    .await;
1863    session.push_flash(FlashLevel::Success, t!("Cropped copy saved."));
1864    Redirect::to(&ctx.url(&new_id.to_string())).into_response()
1865}
1866
1867/// `photo.jpg` cropped becomes `photo-crop.jpg`, in the crop's own format.
1868fn cropped_name(original: &str, extension: &str) -> String {
1869    let stem = match original.rsplit_once('.') {
1870        Some((stem, _)) if !stem.is_empty() => stem,
1871        _ => original,
1872    };
1873    format!("{stem}-crop.{extension}")
1874}
1875
1876#[derive(serde::Deserialize)]
1877struct DetailsForm {
1878    #[serde(default)]
1879    name: String,
1880    #[serde(default)]
1881    title: String,
1882    #[serde(default)]
1883    alt: String,
1884}
1885
1886async fn update_file(
1887    State(app): App,
1888    Extension(session): Extension<SessionHandle>,
1889    Extension(user): Extension<AuthenticatedUser>,
1890    Path(id): Path<i64>,
1891    Form(form): Form<DetailsForm>,
1892) -> Response {
1893    if !user.permissions.allows(MANAGE) {
1894        return forbidden();
1895    }
1896    let ctx = &app.ctx;
1897    let here = ctx.url(&id.to_string());
1898    let Some(r) = load(ctx, id).await else {
1899        return AdminError::NotFound.into_response();
1900    };
1901    let name = form.name.trim();
1902    if name.is_empty() || name.contains('/') || name.contains('\\') || name.len() > 255 {
1903        session.push_flash(
1904            FlashLevel::Error,
1905            t!("Give the file a name without slashes."),
1906        );
1907        return Redirect::to(&here).into_response();
1908    }
1909    let blank = |s: &str| {
1910        let t = s.trim();
1911        (!t.is_empty()).then(|| t.to_string())
1912    };
1913    let title = blank(&form.title);
1914    let alt = blank(&form.alt);
1915    let renamed = if name != r.original_name {
1916        record::rename(ctx.db(), id, name).await
1917    } else {
1918        Ok(())
1919    };
1920    match renamed.and(record::update_details(ctx.db(), id, title.as_deref(), alt.as_deref()).await)
1921    {
1922        Ok(()) => {
1923            ctx.audit(
1924                &user,
1925                "media.update",
1926                Some("media"),
1927                Some(&id.to_string()),
1928                Some(name),
1929                None,
1930            )
1931            .await;
1932            session.push_flash(FlashLevel::Success, t!("File saved."));
1933        }
1934        Err(e) => {
1935            tracing::error!(error = %e, "a media record could not be saved");
1936            session.push_flash(FlashLevel::Error, t!("Could not save. Please try again."));
1937        }
1938    }
1939    Redirect::to(&here).into_response()
1940}
1941
1942async fn move_file(
1943    State(app): App,
1944    Extension(session): Extension<SessionHandle>,
1945    Extension(user): Extension<AuthenticatedUser>,
1946    Path(id): Path<i64>,
1947    Form(form): Form<MoveForm>,
1948) -> Response {
1949    if !user.permissions.allows(MANAGE) {
1950        return forbidden();
1951    }
1952    let ctx = &app.ctx;
1953    let here = ctx.url(&id.to_string());
1954    let to = folder_param(&form.to);
1955    if let Some(to) = to {
1956        if folder::find(ctx.db(), to).await.ok().flatten().is_none() {
1957            session.push_flash(FlashLevel::Error, t!("No such folder."));
1958            return Redirect::to(&here).into_response();
1959        }
1960    }
1961    match record::move_to_folder(ctx.db(), id, to).await {
1962        Ok(()) => {
1963            ctx.audit(
1964                &user,
1965                "media.move",
1966                Some("media"),
1967                Some(&id.to_string()),
1968                None,
1969                Some(&format!(
1970                    "{{\"folder\":{}}}",
1971                    to.map(|f| f.to_string()).unwrap_or("null".into())
1972                )),
1973            )
1974            .await;
1975            session.push_flash(FlashLevel::Success, t!("File moved."));
1976        }
1977        Err(e) => {
1978            tracing::error!(error = %e, "a media record could not be moved");
1979            session.push_flash(FlashLevel::Error, t!("Could not save. Please try again."));
1980        }
1981    }
1982    Redirect::to(&here).into_response()
1983}
1984
1985async fn delete_file(
1986    State(app): App,
1987    Extension(session): Extension<SessionHandle>,
1988    Extension(user): Extension<AuthenticatedUser>,
1989    Path(id): Path<i64>,
1990) -> Response {
1991    let ctx = &app.ctx;
1992    let here = ctx.url(&id.to_string());
1993    let Some(r) = load(ctx, id).await else {
1994        return AdminError::NotFound.into_response();
1995    };
1996    let users = link::users_of(ctx.db(), id).await.unwrap_or_default();
1997    let needed = if users.is_empty() {
1998        DELETE
1999    } else {
2000        DELETE_LINKED
2001    };
2002    if !user.permissions.allows(needed) {
2003        if users.is_empty() {
2004            return forbidden();
2005        }
2006        session.push_flash(
2007            FlashLevel::Error,
2008            t!(
2009                "{n} record(s) still use this file; deleting it needs the permission to delete linked media.",
2010                n = users.len() as i64
2011            ),
2012        );
2013        return Redirect::to(&here).into_response();
2014    }
2015    let folder_id = r.folder_id;
2016    let variants = variant::list_for(ctx.db(), id).await.unwrap_or_default();
2017    let _ = variant::delete_for(ctx.db(), id).await;
2018    match record::delete(ctx.db(), id).await {
2019        Ok(last) => {
2020            if last {
2021                if let Some((driver, _)) = app.disks.by_name.get(&r.disk) {
2022                    for v in &variants {
2023                        let _ = driver.delete(&v.path).await;
2024                    }
2025                    if let Err(e) = driver.delete(&blob_path(&r.hash)).await {
2026                        tracing::warn!(error = %e, hash = %r.hash, "a blob could not be removed");
2027                    }
2028                }
2029            }
2030            ctx.audit(
2031                &user,
2032                "media.delete",
2033                Some("media"),
2034                Some(&id.to_string()),
2035                Some(&r.original_name),
2036                Some(&format!("{{\"links\":{}}}", users.len())),
2037            )
2038            .await;
2039            session.push_flash(FlashLevel::Success, t!("File deleted."));
2040            back_to(ctx, folder_id)
2041        }
2042        Err(e) => {
2043            tracing::error!(error = %e, "a media record could not be deleted");
2044            session.push_flash(FlashLevel::Error, t!("Could not delete. Please try again."));
2045            Redirect::to(&here).into_response()
2046        }
2047    }
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052    use super::*;
2053
2054    #[test]
2055    fn names_are_made_safe_for_a_url() {
2056        assert_eq!(
2057            safe_name("My Photo (1).JPG"),
2058            "My-Photo-1-.JPG".replace("-.", ".")
2059        );
2060        assert_eq!(safe_name("../../etc/passwd"), "etc-passwd");
2061        assert_eq!(safe_name("***"), "file");
2062        assert_eq!(human_size(512), "512 B");
2063        assert_eq!(human_size(2048), "2 KB");
2064        assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
2065    }
2066}