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