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