1use 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
38pub const BROWSE: &str = "media.browse";
40pub const UPLOAD: &str = "media.upload";
42pub const MANAGE: &str = "media.manage";
44pub const DELETE: &str = "media.delete";
46pub const DELETE_LINKED: &str = "media.delete_linked";
48
49pub const DISK: &str = "public";
51const UPLOAD_LIMIT: u64 = 256 * 1024 * 1024;
53const PER_PAGE: u64 = 60;
54
55pub 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 }
72
73pub(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
88pub(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
95pub(crate) fn serve_route() -> PublicRouteReg {
97 PublicRouteReg::new("/storage/media", Arc::new(Serve))
98}
99
100struct 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 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
153pub 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
179pub 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
191pub fn file_url(record: &MediaRecord) -> String {
194 url_for(record, None)
195}
196
197pub 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
219pub const SIGNED_TTL: Duration = Duration::from_secs(60 * 60);
221
222pub fn signed_file_url(ctx: &RouteCtx, record: &MediaRecord, ttl: Duration) -> String {
226 signed_url_for(ctx, record, None, ttl)
227}
228
229pub 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
259fn 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
271pub 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
298fn 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 e: Option<u64>,
310 t: Option<String>,
312}
313
314fn 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 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 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 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#[derive(serde::Deserialize, Default)]
437struct PickQuery {
438 folder: Option<i64>,
439 q: Option<String>,
440 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
581async 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#[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 active: bool,
622}
623
624struct NavLink {
626 label: String,
627 href: String,
628 icon: &'static str,
629 active: bool,
630}
631
632#[derive(Template)]
634#[template(path = "media_actions.html")]
635struct ActionsTemplate {
636 shell: Shell,
637 may_upload: bool,
638 may_manage: bool,
639}
640
641#[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 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 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 library_href: String,
679 upload_url: String,
680 upload_direct: String,
682 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#[derive(serde::Serialize)]
962struct StoredFile {
963 id: i64,
964 name: String,
965 href: String,
966 url: String,
967}
968
969#[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 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 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
1126const 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
1139async 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
1181fn 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
1200async 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
1317fn 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 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 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
1611async 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 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
1630async 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 #[serde(default)]
1656 mode: String,
1657}
1658
1659async 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 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
1829fn 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}