1use axum::{
2 body::{to_bytes, Body},
3 extract::{self, Query, State},
4 http::StatusCode,
5 response, Json,
6};
7use futures_core::Stream;
8use serde::Deserialize;
9use serde_json::json;
10use std::{fmt::Debug, path::PathBuf, pin::Pin};
11use tokio::io::AsyncWriteExt;
12
13use crate::prelude::*;
14use crate::{
15 audio::AudioExtractorTask,
16 descriptor::{self, FileIdGeneratorTask},
17 ffmpeg, filter, image,
18 image::ImageResizerTask,
19 pdf::PdfProcessorTask,
20 preset::{self, get_audio_tier, get_image_tier, get_video_tier, presets},
21 store, svg,
22 variant::{self, VariantClass},
23 video::VideoTranscoderTask,
24};
25use cloudillo_core::extract::{Auth, IdTag, OptionalAuth, OptionalRequestId};
26use cloudillo_types::blob_adapter;
27use cloudillo_types::hasher;
28use cloudillo_types::meta_adapter;
29use cloudillo_types::types::{self, ApiResponse, Timestamp};
30use cloudillo_types::utils;
31
32pub fn format_from_content_type(content_type: &str) -> Option<&str> {
35 Some(match content_type {
36 "image/jpeg" => "jpeg",
38 "image/png" => "png",
39 "image/webp" => "webp",
40 "image/avif" => "avif",
41 "image/gif" => "gif",
42 "image/svg+xml" => "svg",
43 "video/mp4" | "video/quicktime" => "mp4",
45 "video/webm" => "webm",
46 "video/x-matroska" => "mkv",
47 "video/x-msvideo" => "avi",
48 "audio/mpeg" => "mp3",
50 "audio/wav" => "wav",
51 "audio/ogg" => "ogg",
52 "audio/flac" => "flac",
53 "audio/aac" => "aac",
54 "audio/webm" => "weba",
55 "application/pdf" => "pdf",
57 _ => None?,
58 })
59}
60
61async fn stream_body_to_file(body: Body, path: &PathBuf) -> ClResult<u64> {
63 let mut file = tokio::fs::File::create(path).await?;
64 let mut body_stream = body.into_data_stream();
65 let mut total_size: u64 = 0;
66
67 use futures::StreamExt;
68 while let Some(chunk) = body_stream.next().await {
69 let chunk = chunk.map_err(|e| Error::Internal(format!("body read error: {}", e)))?;
70 total_size += chunk.len() as u64;
71 file.write_all(&chunk).await?;
72 }
73 file.flush().await?;
74
75 Ok(total_size)
76}
77
78pub fn content_type_from_format(format: &str) -> &str {
79 match format {
80 "jpeg" => "image/jpeg",
81 "png" => "image/png",
82 "webp" => "image/webp",
83 "avif" => "image/avif",
84 "svg" => "image/svg+xml",
85 _ => "application/octet-stream",
86 }
87}
88
89fn serve_file<S: AsRef<str> + Debug>(
90 descriptor: Option<&str>,
91 variant: &meta_adapter::FileVariant<S>,
92 stream: Pin<Box<dyn Stream<Item = Result<axum::body::Bytes, std::io::Error>> + Send>>,
93 disable_cache: bool,
94) -> ClResult<response::Response<axum::body::Body>> {
95 let content_type = content_type_from_format(variant.format.as_ref());
96
97 let mut response = axum::response::Response::builder()
98 .header(axum::http::header::CONTENT_TYPE, content_type)
99 .header(axum::http::header::CONTENT_LENGTH, variant.size);
100
101 if disable_cache {
103 response = response.header(axum::http::header::CACHE_CONTROL, "no-store, no-cache");
104 } else {
105 response = response
107 .header(axum::http::header::CACHE_CONTROL, "public, max-age=31536000, immutable");
108 }
109
110 response = response.header("X-Cloudillo-Variant", variant.variant_id.as_ref());
111 if let Some(descriptor) = descriptor {
112 response = response.header("X-Cloudillo-Variants", descriptor);
113 };
114
115 if content_type == "image/svg+xml" {
117 response = response
118 .header("Content-Security-Policy", "script-src 'none'; object-src 'none'")
119 .header("X-Content-Type-Options", "nosniff");
120 }
121
122 Ok(response.body(axum::body::Body::from_stream(stream))?)
123}
124
125pub async fn get_file_list(
127 State(app): State<App>,
128 tn_id: TnId,
129 IdTag(tenant_id_tag): IdTag,
130 OptionalAuth(maybe_auth): OptionalAuth,
131 Query(mut opts): Query<meta_adapter::ListFileOptions>,
132 OptionalRequestId(req_id): OptionalRequestId,
133) -> ClResult<(StatusCode, Json<ApiResponse<Vec<meta_adapter::FileView>>>)> {
134 let (subject_id_tag, is_authenticated, subject_roles) = match &maybe_auth {
136 Some(auth) => {
137 opts.user_id_tag = Some(auth.id_tag.to_string());
138 (auth.id_tag.as_ref(), true, &auth.roles[..])
139 }
140 None => ("", false, &[][..]),
141 };
142
143 let limit = opts.limit.unwrap_or(30) as usize;
144 let sort_field = opts.sort.as_deref().unwrap_or("created");
145
146 let files = app.meta_adapter.list_files(tn_id, &opts).await?;
147
148 let mut filtered = filter::filter_files_by_visibility(
150 &app,
151 tn_id,
152 subject_id_tag,
153 is_authenticated,
154 &tenant_id_tag,
155 subject_roles,
156 files,
157 )
158 .await?;
159
160 let has_more = filtered.len() > limit;
162 if has_more {
163 filtered.truncate(limit);
164 }
165
166 let next_cursor = if has_more && !filtered.is_empty() {
168 let last = filtered.last().ok_or(Error::Internal("no last item".into()))?;
169 let sort_value = match sort_field {
170 "recent" => {
171 let ts = last
173 .user_data
174 .as_ref()
175 .and_then(|ud| ud.accessed_at)
176 .unwrap_or(last.created_at);
177 serde_json::Value::Number(ts.0.into())
178 }
179 "modified" => {
180 let ts = last
182 .user_data
183 .as_ref()
184 .and_then(|ud| ud.modified_at)
185 .unwrap_or(last.created_at);
186 serde_json::Value::Number(ts.0.into())
187 }
188 "name" => serde_json::Value::String(last.file_name.to_string()),
189 _ => serde_json::Value::Number(last.created_at.0.into()),
190 };
191 let cursor = types::CursorData::new(sort_field, sort_value, &last.file_id);
192 Some(cursor.encode())
193 } else {
194 None
195 };
196
197 let response = ApiResponse::with_cursor_pagination(filtered, next_cursor, has_more)
198 .with_req_id(req_id.unwrap_or_default());
199
200 Ok((StatusCode::OK, Json(response)))
201}
202
203pub async fn get_file_variant(
205 State(app): State<App>,
206 tn_id: TnId,
207 extract::Path(variant_id): extract::Path<String>,
208) -> ClResult<impl response::IntoResponse> {
209 let variant = app.meta_adapter.read_file_variant(tn_id, &variant_id).await?;
210 info!("variant: {:?}", variant);
211 let stream = app.blob_adapter.read_blob_stream(tn_id, &variant_id).await?;
212
213 serve_file(None, &variant, stream, app.opts.disable_cache)
214}
215
216#[derive(Debug, Clone, Default, Deserialize)]
217pub struct GetFileVariantSelector {
218 pub variant: Option<String>,
219 pub min_x: Option<u32>,
220 pub min_y: Option<u32>,
221 pub min_res: Option<u32>, }
223
224pub async fn get_file_variant_file_id(
225 State(app): State<App>,
226 tn_id: TnId,
227 extract::Path(file_id): extract::Path<String>,
228 extract::Query(selector): extract::Query<GetFileVariantSelector>,
229) -> ClResult<impl response::IntoResponse> {
230 let mut variants = app
231 .meta_adapter
232 .list_file_variants(tn_id, meta_adapter::FileId::FileId(&file_id))
233 .await?;
234 variants.sort();
235 debug!("variants: {:?}", variants);
236
237 let variant = descriptor::get_best_file_variant(&variants, &selector)?;
238 let stream = app.blob_adapter.read_blob_stream(tn_id, &variant.variant_id).await?;
239 let descriptor = descriptor::get_file_descriptor(&variants);
240
241 serve_file(Some(&descriptor), variant, stream, app.opts.disable_cache)
242}
243
244pub async fn get_file_descriptor(
245 State(app): State<App>,
246 tn_id: TnId,
247 extract::Path(file_id): extract::Path<String>,
248 OptionalRequestId(req_id): OptionalRequestId,
249) -> ClResult<(StatusCode, Json<ApiResponse<String>>)> {
250 let mut variants = app
251 .meta_adapter
252 .list_file_variants(tn_id, meta_adapter::FileId::FileId(&file_id))
253 .await?;
254 variants.sort();
255
256 let descriptor = descriptor::get_file_descriptor(&variants);
257
258 let response = ApiResponse::new(descriptor).with_req_id(req_id.unwrap_or_default());
259
260 Ok((StatusCode::OK, Json(response)))
261}
262
263#[derive(Deserialize)]
264pub struct PostFileQuery {
265 created_at: Option<Timestamp>,
266 tags: Option<String>,
267 visibility: Option<char>,
269}
270
271#[derive(Deserialize)]
272pub struct PostFileRequest {
273 #[serde(rename = "fileTp")]
274 file_tp: String, #[serde(rename = "contentType")]
276 content_type: Option<String>, #[serde(rename = "fileName")]
278 file_name: Option<String>,
279 #[serde(rename = "parentId")]
280 parent_id: Option<String>,
281 created_at: Option<Timestamp>,
282 tags: Option<String>,
283 visibility: Option<char>,
285}
286
287async fn handle_post_image(
288 app: &App,
289 tn_id: types::TnId,
290 f_id: u64,
291 _content_type: &str,
292 bytes: &[u8],
293 preset: &preset::FilePreset,
294) -> ClResult<serde_json::Value> {
295 let result = image::generate_image_variants(app, tn_id, f_id, bytes, preset).await?;
296
297 Ok(json!({
298 "fileId": format!("@{}", f_id),
299 "thumbnailVariantId": result.thumbnail_variant_id,
300 "dim": [result.dim.0, result.dim.1]
301 }))
302}
303
304async fn handle_post_svg(
306 app: &App,
307 tn_id: types::TnId,
308 f_id: u64,
309 bytes: &[u8],
310 preset: &preset::FilePreset,
311) -> ClResult<serde_json::Value> {
312 let sanitized = svg::sanitize_svg(bytes)?;
314 info!("SVG sanitized: {} -> {} bytes", bytes.len(), sanitized.len());
315
316 let (orig_width, orig_height) = svg::parse_svg_dimensions(&sanitized)?;
318 info!("SVG dimensions: {}x{}", orig_width, orig_height);
319
320 let thumbnail_format_str = app
322 .settings
323 .get_string(tn_id, "file.thumbnail_format")
324 .await
325 .unwrap_or_else(|_| "webp".to_string());
326 let thumbnail_format: image::ImageFormat =
327 thumbnail_format_str.parse().unwrap_or(image::ImageFormat::Webp);
328
329 let sd_variant_id = if preset.store_original {
335 store::create_blob_buf(app, tn_id, &sanitized, blob_adapter::CreateBlobOptions::default())
336 .await?
337 } else {
338 hasher::hash("b", &sanitized)
339 };
340
341 app.meta_adapter
343 .create_file_variant(
344 tn_id,
345 f_id,
346 meta_adapter::FileVariant {
347 variant_id: sd_variant_id.as_ref(),
348 variant: "vis.sd",
349 format: "svg",
350 resolution: (orig_width, orig_height),
351 size: sanitized.len() as u64,
352 available: preset.store_original,
353 duration: None,
354 bitrate: None,
355 page_count: None,
356 },
357 )
358 .await?;
359
360 let thumbnail_variant = preset.thumbnail_variant.as_deref().unwrap_or("vis.tn");
362 let thumbnail_tier = preset::get_image_tier(thumbnail_variant);
363 let tn_format = thumbnail_tier.and_then(|t| t.format).unwrap_or(thumbnail_format);
364 let tn_max_dim = thumbnail_tier.map(|t| t.max_dim).unwrap_or(256);
365
366 let resized_tn = svg::rasterize_svg_sync(&sanitized, tn_format, (tn_max_dim, tn_max_dim))?;
368
369 let thumbnail_variant_id = store::create_blob_buf(
370 app,
371 tn_id,
372 &resized_tn.bytes,
373 blob_adapter::CreateBlobOptions::default(),
374 )
375 .await?;
376
377 app.meta_adapter
378 .create_file_variant(
379 tn_id,
380 f_id,
381 meta_adapter::FileVariant {
382 variant_id: thumbnail_variant_id.as_ref(),
383 variant: thumbnail_variant,
384 format: tn_format.as_ref(),
385 resolution: (resized_tn.width, resized_tn.height),
386 size: resized_tn.bytes.len() as u64,
387 available: true,
388 duration: None,
389 bitrate: None,
390 page_count: None,
391 },
392 )
393 .await?;
394
395 info!(
396 "SVG thumbnail created: {}x{} ({} bytes)",
397 resized_tn.width,
398 resized_tn.height,
399 resized_tn.bytes.len()
400 );
401
402 app.scheduler
404 .task(FileIdGeneratorTask::new(tn_id, f_id))
405 .key(format!("{},{}", tn_id, f_id))
406 .schedule()
407 .await?;
408
409 Ok(json!({
410 "fileId": format!("@{}", f_id),
411 "thumbnailVariantId": thumbnail_variant_id,
412 "dim": [orig_width, orig_height]
413 }))
414}
415
416async fn handle_post_video_stream(
418 app: &App,
419 tn_id: types::TnId,
420 f_id: u64,
421 content_type: &str,
422 body: Body,
423 preset: &preset::FilePreset,
424) -> ClResult<serde_json::Value> {
425 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
427 let total_size = stream_body_to_file(body, &temp_path).await?;
428 info!("Video upload streamed to {:?}, size: {} bytes", temp_path, total_size);
429
430 let media_info = ffmpeg::FFmpeg::probe(&temp_path)
432 .map_err(|e| Error::Internal(format!("ffprobe failed: {}", e)))?;
433 let duration = media_info.duration;
434 let resolution = media_info.video_resolution().unwrap_or((0, 0));
435 info!("Video info: duration={:.2}s, resolution={}x{}", duration, resolution.0, resolution.1);
436
437 let max_quality_str = app
439 .settings
440 .get_string(tn_id, "file.max_generate_variant")
441 .await
442 .unwrap_or_else(|_| "hd".to_string());
443 let max_quality =
444 variant::parse_quality(&max_quality_str).unwrap_or(variant::VariantQuality::High);
445
446 if app.settings.get_bool(tn_id, "file.store_original_vid").await.unwrap_or(false) {
448 let orig_blob_id = store::create_blob_from_file(
449 app,
450 tn_id,
451 &temp_path,
452 blob_adapter::CreateBlobOptions::default(),
453 )
454 .await?;
455 app.meta_adapter
456 .create_file_variant(
457 tn_id,
458 f_id,
459 meta_adapter::FileVariant {
460 variant_id: &orig_blob_id,
461 variant: "vid.orig",
462 format: format_from_content_type(content_type).unwrap_or("mp4"),
463 resolution,
464 size: total_size,
465 available: true,
466 duration: Some(duration),
467 bitrate: None,
468 page_count: None,
469 },
470 )
471 .await?;
472 }
473
474 let frame_path = app.opts.tmp_dir.join(format!("frame_{}.jpg", f_id));
476
477 let seek_time = if duration > 10.0 {
479 (duration * 0.1).max(3.0).min(duration - 1.0)
480 } else if duration > 1.0 {
481 duration / 2.0
482 } else {
483 0.0
484 };
485
486 ffmpeg::FFmpeg::extract_frame(&temp_path, &frame_path, seek_time)
488 .map_err(|e| Error::Internal(format!("thumbnail extraction failed: {}", e)))?;
489
490 let frame_bytes = tokio::fs::read(&frame_path).await?;
492
493 let thumbnail_result =
494 image::resize_image(app.clone(), frame_bytes, image::ImageFormat::Webp, (256, 256))
495 .await
496 .map_err(|e| Error::Internal(format!("thumbnail resize failed: {}", e)))?;
497
498 let thumbnail_variant_id = store::create_blob_buf(
500 app,
501 tn_id,
502 &thumbnail_result.bytes,
503 blob_adapter::CreateBlobOptions::default(),
504 )
505 .await?;
506
507 app.meta_adapter
509 .create_file_variant(
510 tn_id,
511 f_id,
512 meta_adapter::FileVariant {
513 variant_id: &thumbnail_variant_id,
514 variant: "vis.tn",
515 format: "webp",
516 resolution: (thumbnail_result.width, thumbnail_result.height),
517 size: thumbnail_result.bytes.len() as u64,
518 available: true,
519 duration: None,
520 bitrate: None,
521 page_count: None,
522 },
523 )
524 .await?;
525
526 info!(
527 "Video thumbnail extracted: {}x{} ({} bytes)",
528 thumbnail_result.width,
529 thumbnail_result.height,
530 thumbnail_result.bytes.len()
531 );
532
533 let mut task_ids = Vec::new();
535
536 for variant_name in &preset.image_variants {
538 if variant_name == "vis.tn" {
539 continue; }
541 if let Some(parsed) = variant::Variant::parse(variant_name) {
543 if parsed.quality > max_quality {
544 continue;
545 }
546 }
547 if let Some(tier) = get_image_tier(variant_name) {
548 let task = ImageResizerTask::new(
549 tn_id,
550 f_id,
551 frame_path.clone(),
552 variant_name.clone(),
553 image::ImageFormat::Webp,
554 (tier.max_dim, tier.max_dim),
555 );
556 task_ids.push(app.scheduler.add(task).await?);
557 }
558 }
559
560 for variant_name in &preset.video_variants {
562 if let Some(parsed) = variant::Variant::parse(variant_name) {
564 if parsed.quality > max_quality {
565 continue;
566 }
567 }
568 if let Some(tier) = get_video_tier(variant_name) {
569 let task = VideoTranscoderTask::new(
570 tn_id,
571 f_id,
572 temp_path.clone(),
573 variant_name.as_str(),
574 tier.max_dim,
575 tier.bitrate,
576 );
577 task_ids.push(app.scheduler.add(task).await?);
578 }
579 }
580
581 if preset.extract_audio {
583 for variant_name in &preset.audio_variants {
584 if let Some(parsed) = variant::Variant::parse(variant_name) {
586 if parsed.quality > max_quality {
587 continue;
588 }
589 }
590 if let Some(tier) = get_audio_tier(variant_name) {
591 let task = AudioExtractorTask::new(
592 tn_id,
593 f_id,
594 temp_path.clone(),
595 variant_name.as_str(),
596 tier.bitrate,
597 );
598 task_ids.push(app.scheduler.add(task).await?);
599 }
600 }
601 }
602
603 let mut builder = app
605 .scheduler
606 .task(FileIdGeneratorTask::new(tn_id, f_id))
607 .key(format!("{},{}", tn_id, f_id));
608 if !task_ids.is_empty() {
609 builder = builder.depend_on(task_ids);
610 }
611 builder.schedule().await?;
612
613 Ok(json!({
614 "fileId": format!("@{}", f_id),
615 "duration": duration,
616 "resolution": [resolution.0, resolution.1],
617 "thumbnailVariantId": thumbnail_variant_id
618 }))
619}
620
621async fn handle_post_audio_stream(
623 app: &App,
624 tn_id: types::TnId,
625 f_id: u64,
626 content_type: &str,
627 body: Body,
628 preset: &preset::FilePreset,
629) -> ClResult<serde_json::Value> {
630 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
632 let total_size = stream_body_to_file(body, &temp_path).await?;
633 info!("Audio upload streamed to {:?}, size: {} bytes", temp_path, total_size);
634
635 let media_info = ffmpeg::FFmpeg::probe(&temp_path)
637 .map_err(|e| Error::Internal(format!("ffprobe failed: {}", e)))?;
638 let duration = media_info.duration;
639 info!("Audio info: duration={:.2}s", duration);
640
641 let max_quality_str = app
643 .settings
644 .get_string(tn_id, "file.max_generate_variant")
645 .await
646 .unwrap_or_else(|_| "hd".to_string());
647 let max_quality =
648 variant::parse_quality(&max_quality_str).unwrap_or(variant::VariantQuality::High);
649
650 if app.settings.get_bool(tn_id, "file.store_original_aud").await.unwrap_or(false) {
652 let orig_blob_id = store::create_blob_from_file(
653 app,
654 tn_id,
655 &temp_path,
656 blob_adapter::CreateBlobOptions::default(),
657 )
658 .await?;
659 app.meta_adapter
660 .create_file_variant(
661 tn_id,
662 f_id,
663 meta_adapter::FileVariant {
664 variant_id: &orig_blob_id,
665 variant: "aud.orig",
666 format: format_from_content_type(content_type).unwrap_or("mp3"),
667 resolution: (0, 0),
668 size: total_size,
669 available: true,
670 duration: Some(duration),
671 bitrate: None,
672 page_count: None,
673 },
674 )
675 .await?;
676 }
677
678 let mut task_ids = Vec::new();
680 for variant_name in &preset.audio_variants {
681 if let Some(parsed) = variant::Variant::parse(variant_name) {
683 if parsed.quality > max_quality {
684 continue;
685 }
686 }
687 if let Some(tier) = get_audio_tier(variant_name) {
688 let task = AudioExtractorTask::new(
689 tn_id,
690 f_id,
691 temp_path.clone(),
692 variant_name.as_str(),
693 tier.bitrate,
694 );
695 task_ids.push(app.scheduler.add(task).await?);
696 }
697 }
698
699 let mut builder = app
701 .scheduler
702 .task(FileIdGeneratorTask::new(tn_id, f_id))
703 .key(format!("{},{}", tn_id, f_id));
704 if !task_ids.is_empty() {
705 builder = builder.depend_on(task_ids);
706 }
707 builder.schedule().await?;
708
709 Ok(json!({
710 "fileId": format!("@{}", f_id),
711 "duration": duration
712 }))
713}
714
715async fn handle_post_pdf(
717 app: &App,
718 tn_id: types::TnId,
719 f_id: u64,
720 bytes: &[u8],
721) -> ClResult<serde_json::Value> {
722 let orig_blob_id =
724 store::create_blob_buf(app, tn_id, bytes, blob_adapter::CreateBlobOptions::default())
725 .await?;
726
727 app.meta_adapter
728 .create_file_variant(
729 tn_id,
730 f_id,
731 meta_adapter::FileVariant {
732 variant_id: &orig_blob_id,
733 variant: "doc.orig",
734 format: "pdf",
735 resolution: (0, 0),
736 size: bytes.len() as u64,
737 available: true,
738 duration: None,
739 bitrate: None,
740 page_count: None, },
742 )
743 .await?;
744
745 let temp_path = app.opts.tmp_dir.join(format!("pdf_{}_{}", tn_id.0, f_id));
747 tokio::fs::write(&temp_path, bytes).await?;
748
749 let pdf_task = PdfProcessorTask::new(tn_id, f_id, temp_path.clone(), 256);
751 let task_id = app.scheduler.add(pdf_task).await?;
752
753 app.scheduler
755 .task(FileIdGeneratorTask::new(tn_id, f_id))
756 .key(format!("{},{}", tn_id, f_id))
757 .depend_on(vec![task_id])
758 .schedule()
759 .await?;
760
761 Ok(json!({"fileId": format!("@{}", f_id)}))
762}
763
764async fn handle_post_raw_stream(
766 app: &App,
767 tn_id: types::TnId,
768 f_id: u64,
769 content_type: &str,
770 body: Body,
771) -> ClResult<serde_json::Value> {
772 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
774 let total_size = stream_body_to_file(body, &temp_path).await?;
775 info!("Raw upload streamed to {:?}, size: {} bytes", temp_path, total_size);
776
777 let orig_blob_id = store::create_blob_from_file(
779 app,
780 tn_id,
781 &temp_path,
782 blob_adapter::CreateBlobOptions::default(),
783 )
784 .await?;
785
786 let format = format_from_content_type(content_type).unwrap_or("bin");
788
789 app.meta_adapter
790 .create_file_variant(
791 tn_id,
792 f_id,
793 meta_adapter::FileVariant {
794 variant_id: &orig_blob_id,
795 variant: "raw.orig",
796 format,
797 resolution: (0, 0),
798 size: total_size,
799 available: true,
800 duration: None,
801 bitrate: None,
802 page_count: None,
803 },
804 )
805 .await?;
806
807 let _ = tokio::fs::remove_file(&temp_path).await;
809
810 app.scheduler
812 .task(FileIdGeneratorTask::new(tn_id, f_id))
813 .key(format!("{},{}", tn_id, f_id))
814 .schedule()
815 .await?;
816
817 Ok(json!({"fileId": format!("@{}", f_id)}))
818}
819
820pub async fn post_file(
828 State(app): State<App>,
829 tn_id: TnId,
830 Auth(auth): Auth,
831 OptionalRequestId(req_id): OptionalRequestId,
832 extract::Json(req): extract::Json<PostFileRequest>,
833) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
834 use tracing::info;
835
836 info!("POST /api/files - Creating file with fileTp={}", req.file_tp);
837
838 let file_id = utils::random_id()?;
840
841 let tenant_meta = app.meta_adapter.read_tenant(tn_id).await?;
843 let visibility = match req.visibility {
844 Some(v) => Some(v),
845 None if matches!(tenant_meta.typ, meta_adapter::ProfileType::Community) => Some('C'),
846 None => None,
847 };
848
849 let content_type = req.content_type.clone().unwrap_or_else(|| "application/json".to_string());
851 let _f_id = app
852 .meta_adapter
853 .create_file(
854 tn_id,
855 meta_adapter::CreateFile {
856 preset: Some("default".into()),
857 orig_variant_id: Some(file_id.clone().into()),
858 file_id: Some(file_id.clone().into()),
859 parent_id: req.parent_id.map(Into::into),
860 owner_tag: None,
861 creator_tag: Some(auth.id_tag.clone()),
862 content_type: content_type.into(),
863 file_name: req.file_name.clone().unwrap_or_else(|| "file".into()).into(),
864 file_tp: Some(req.file_tp.clone().into()),
865 created_at: req.created_at,
866 tags: req.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
867 x: None,
868 visibility,
869 status: None,
870 },
871 )
872 .await?;
873
874 info!("Created file metadata for fileTp={} by {}", req.file_tp, auth.id_tag);
875
876 let data = json!({"fileId": file_id});
877
878 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
879
880 Ok((StatusCode::CREATED, Json(response)))
881}
882
883#[allow(clippy::too_many_arguments)]
884pub async fn post_file_blob(
885 State(app): State<App>,
886 tn_id: TnId,
887 Auth(auth): Auth,
888 extract::Path((preset_name, file_name)): extract::Path<(String, String)>,
889 query: Query<PostFileQuery>,
890 header: axum::http::HeaderMap,
891 OptionalRequestId(req_id): OptionalRequestId,
892 body: Body,
893) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
894 let content_type = header
895 .get(axum::http::header::CONTENT_TYPE)
896 .and_then(|v| v.to_str().ok())
897 .unwrap_or("application/octet-stream");
898 info!("post_file_blob: preset={}, content_type={}", preset_name, content_type);
899
900 let tenant_meta = app.meta_adapter.read_tenant(tn_id).await?;
902 let visibility = match query.visibility {
903 Some(v) => Some(v),
904 None if matches!(tenant_meta.typ, meta_adapter::ProfileType::Community) => Some('C'),
905 None => None,
906 };
907
908 let preset = presets::get(&preset_name).unwrap_or_else(presets::default);
910
911 let media_class = VariantClass::from_content_type(content_type);
913
914 let media_class = match media_class {
916 Some(class) if preset.allowed_media_classes.contains(&class) => class,
917 Some(class) => {
918 return Err(Error::ValidationError(format!(
919 "preset '{}' does not allow {:?} uploads",
920 preset.name, class
921 )))
922 }
923 None if preset.allowed_media_classes.contains(&VariantClass::Raw) => VariantClass::Raw,
924 None => return Err(Error::ValidationError("unsupported media type".into())),
925 };
926
927 info!("Media class: {:?}", media_class);
928
929 const BYTES_PER_MIB: usize = 1_048_576; const DEFAULT_MAX_SIZE_MIB: i64 = 50;
932
933 let max_size_mib = app
934 .settings
935 .get_int(tn_id, "file.max_file_size_mb")
936 .await
937 .unwrap_or(DEFAULT_MAX_SIZE_MIB)
938 .max(1); let max_size_bytes = (max_size_mib as usize) * BYTES_PER_MIB;
941
942 match media_class {
944 VariantClass::Visual => {
946 let bytes = to_bytes(body, max_size_bytes).await?;
947 let orig_variant_id = hasher::hash("b", &bytes);
948
949 let is_svg = content_type == "image/svg+xml"
951 || (content_type == "application/octet-stream" && svg::is_svg(&bytes));
952
953 let dim = if is_svg {
955 svg::parse_svg_dimensions(&bytes)?
956 } else {
957 image::get_image_dimensions(&bytes).await?
958 };
959 info!("Image dimensions: {}/{} (SVG: {})", dim.0, dim.1, is_svg);
960
961 let f_id = app
962 .meta_adapter
963 .create_file(
964 tn_id,
965 meta_adapter::CreateFile {
966 preset: Some(preset_name.clone().into()),
967 orig_variant_id: Some(orig_variant_id),
968 file_id: None,
969 parent_id: None,
970 owner_tag: None,
971 creator_tag: Some(auth.id_tag.clone()),
972 content_type: if is_svg {
973 "image/svg+xml".into()
974 } else {
975 content_type.into()
976 },
977 file_name: file_name.into(),
978 file_tp: Some("BLOB".into()),
979 created_at: query.created_at,
980 tags: query.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
981 x: Some(json!({ "dim": dim })),
982 visibility,
983 status: None,
984 },
985 )
986 .await?;
987
988 match f_id {
989 meta_adapter::FileId::FId(f_id) => {
990 let data = if is_svg {
992 handle_post_svg(&app, tn_id, f_id, &bytes, &preset).await?
993 } else {
994 handle_post_image(&app, tn_id, f_id, content_type, &bytes, &preset).await?
995 };
996 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
997 Ok((StatusCode::CREATED, Json(response)))
998 }
999 meta_adapter::FileId::FileId(file_id) => {
1000 let data = json!({"fileId": file_id});
1001 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1002 Ok((StatusCode::CREATED, Json(response)))
1003 }
1004 }
1005 }
1006
1007 VariantClass::Document => {
1008 let bytes = to_bytes(body, max_size_bytes).await?;
1009 let orig_variant_id = hasher::hash("b", &bytes);
1010
1011 let f_id = app
1012 .meta_adapter
1013 .create_file(
1014 tn_id,
1015 meta_adapter::CreateFile {
1016 preset: Some(preset_name.clone().into()),
1017 orig_variant_id: Some(orig_variant_id),
1018 file_id: None,
1019 parent_id: None,
1020 owner_tag: None,
1021 creator_tag: Some(auth.id_tag.clone()),
1022 content_type: content_type.into(),
1023 file_name: file_name.into(),
1024 file_tp: Some("BLOB".into()),
1025 created_at: query.created_at,
1026 tags: query.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
1027 x: None,
1028 visibility,
1029 status: None,
1030 },
1031 )
1032 .await?;
1033
1034 match f_id {
1035 meta_adapter::FileId::FId(f_id) => {
1036 let data = handle_post_pdf(&app, tn_id, f_id, &bytes).await?;
1037 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1038 Ok((StatusCode::CREATED, Json(response)))
1039 }
1040 meta_adapter::FileId::FileId(file_id) => {
1041 let data = json!({"fileId": file_id});
1042 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1043 Ok((StatusCode::CREATED, Json(response)))
1044 }
1045 }
1046 }
1047
1048 VariantClass::Video => {
1050 let f_id = app
1051 .meta_adapter
1052 .create_file(
1053 tn_id,
1054 meta_adapter::CreateFile {
1055 preset: Some(preset_name.clone().into()),
1056 orig_variant_id: None,
1057 file_id: None,
1058 parent_id: None,
1059 owner_tag: None,
1060 creator_tag: Some(auth.id_tag.clone()),
1061 content_type: content_type.into(),
1062 file_name: file_name.into(),
1063 file_tp: Some("BLOB".into()),
1064 created_at: query.created_at,
1065 tags: query.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
1066 x: None,
1067 visibility,
1068 status: None,
1069 },
1070 )
1071 .await?;
1072
1073 match f_id {
1074 meta_adapter::FileId::FId(f_id) => {
1075 let data =
1076 handle_post_video_stream(&app, tn_id, f_id, content_type, body, &preset)
1077 .await?;
1078 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1079 Ok((StatusCode::CREATED, Json(response)))
1080 }
1081 meta_adapter::FileId::FileId(file_id) => {
1082 let data = json!({"fileId": file_id});
1083 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1084 Ok((StatusCode::CREATED, Json(response)))
1085 }
1086 }
1087 }
1088
1089 VariantClass::Audio => {
1090 let f_id = app
1091 .meta_adapter
1092 .create_file(
1093 tn_id,
1094 meta_adapter::CreateFile {
1095 preset: Some(preset_name.clone().into()),
1096 orig_variant_id: None,
1097 file_id: None,
1098 parent_id: None,
1099 owner_tag: None,
1100 creator_tag: Some(auth.id_tag.clone()),
1101 content_type: content_type.into(),
1102 file_name: file_name.into(),
1103 file_tp: Some("BLOB".into()),
1104 created_at: query.created_at,
1105 tags: query.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
1106 x: None,
1107 visibility,
1108 status: None,
1109 },
1110 )
1111 .await?;
1112
1113 match f_id {
1114 meta_adapter::FileId::FId(f_id) => {
1115 let data =
1116 handle_post_audio_stream(&app, tn_id, f_id, content_type, body, &preset)
1117 .await?;
1118 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1119 Ok((StatusCode::CREATED, Json(response)))
1120 }
1121 meta_adapter::FileId::FileId(file_id) => {
1122 let data = json!({"fileId": file_id});
1123 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1124 Ok((StatusCode::CREATED, Json(response)))
1125 }
1126 }
1127 }
1128
1129 VariantClass::Raw => {
1130 let f_id = app
1131 .meta_adapter
1132 .create_file(
1133 tn_id,
1134 meta_adapter::CreateFile {
1135 preset: Some(preset_name.clone().into()),
1136 orig_variant_id: None,
1137 file_id: None,
1138 parent_id: None,
1139 owner_tag: None,
1140 creator_tag: Some(auth.id_tag.clone()),
1141 content_type: content_type.into(),
1142 file_name: file_name.into(),
1143 file_tp: Some("BLOB".into()),
1144 created_at: query.created_at,
1145 tags: query.tags.as_ref().map(|s| s.split(",").map(|s| s.into()).collect()),
1146 x: None,
1147 visibility,
1148 status: None,
1149 },
1150 )
1151 .await?;
1152
1153 match f_id {
1154 meta_adapter::FileId::FId(f_id) => {
1155 let data =
1156 handle_post_raw_stream(&app, tn_id, f_id, content_type, body).await?;
1157 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1158 Ok((StatusCode::CREATED, Json(response)))
1159 }
1160 meta_adapter::FileId::FileId(file_id) => {
1161 let data = json!({"fileId": file_id});
1162 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1163 Ok((StatusCode::CREATED, Json(response)))
1164 }
1165 }
1166 }
1167 }
1168}
1169
1170