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