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