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,
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::abac::SubjectAccessLevel;
26use cloudillo_core::extract::{Auth, IdTag, OptionalAuth, OptionalRequestId};
27use cloudillo_core::file_access;
28use cloudillo_types::blob_adapter;
29use cloudillo_types::hasher;
30use cloudillo_types::meta_adapter;
31use cloudillo_types::types::{self, ApiResponse, TokenScope};
32use cloudillo_types::utils;
33
34pub fn format_from_content_type(content_type: &str) -> Option<&str> {
37 Some(match content_type {
38 "image/jpeg" => "jpeg",
40 "image/png" => "png",
41 "image/webp" => "webp",
42 "image/avif" => "avif",
43 "image/gif" => "gif",
44 "image/svg+xml" => "svg",
45 "video/mp4" | "video/quicktime" => "mp4",
47 "video/webm" => "webm",
48 "video/x-matroska" => "mkv",
49 "video/x-msvideo" => "avi",
50 "audio/mpeg" => "mp3",
52 "audio/wav" => "wav",
53 "audio/ogg" => "ogg",
54 "audio/flac" => "flac",
55 "audio/aac" => "aac",
56 "audio/webm" => "weba",
57 "application/pdf" => "pdf",
59 _ => None?,
60 })
61}
62
63async fn stream_body_to_file(body: Body, path: &PathBuf) -> ClResult<u64> {
65 use futures::StreamExt;
66
67 let mut file = tokio::fs::File::create(path).await?;
68 let mut body_stream = body.into_data_stream();
69 let mut total_size: u64 = 0;
70
71 while let Some(chunk) = body_stream.next().await {
72 let chunk = chunk.map_err(|e| Error::Internal(format!("body read error: {}", e)))?;
73 total_size += chunk.len() as u64;
74 file.write_all(&chunk).await?;
75 }
76 file.flush().await?;
77
78 Ok(total_size)
79}
80
81pub fn content_type_from_format(format: &str) -> &str {
82 match format {
83 "jpeg" => "image/jpeg",
85 "png" => "image/png",
86 "webp" => "image/webp",
87 "avif" => "image/avif",
88 "gif" => "image/gif",
89 "svg" => "image/svg+xml",
90 "mp4" => "video/mp4",
92 "webm" => "video/webm",
93 "mkv" => "video/x-matroska",
94 "avi" => "video/x-msvideo",
95 "mp3" => "audio/mpeg",
97 "wav" => "audio/wav",
98 "ogg" => "audio/ogg",
99 "flac" => "audio/flac",
100 "aac" => "audio/aac",
101 "weba" => "audio/webm",
102 "pdf" => "application/pdf",
104 _ => "application/octet-stream",
105 }
106}
107
108fn serve_file<S: AsRef<str> + Debug>(
109 descriptor: Option<&str>,
110 variant: &meta_adapter::FileVariant<S>,
111 stream: Pin<Box<dyn Stream<Item = Result<axum::body::Bytes, std::io::Error>> + Send>>,
112 disable_cache: bool,
113) -> ClResult<response::Response<axum::body::Body>> {
114 let content_type = content_type_from_format(variant.format.as_ref());
115
116 let mut response = axum::response::Response::builder()
117 .header(axum::http::header::CONTENT_TYPE, content_type)
118 .header(axum::http::header::CONTENT_LENGTH, variant.size);
119
120 if disable_cache {
122 response = response.header(axum::http::header::CACHE_CONTROL, "no-store, no-cache");
123 } else {
124 response = response
126 .header(axum::http::header::CACHE_CONTROL, "public, max-age=31536000, immutable");
127 }
128
129 response = response.header("X-Cloudillo-Variant", variant.variant_id.as_ref());
130 if let Some(descriptor) = descriptor {
131 response = response.header("X-Cloudillo-Variants", descriptor);
132 }
133
134 if content_type == "image/svg+xml" {
136 response = response
137 .header("Content-Security-Policy", "script-src 'none'; object-src 'none'")
138 .header("X-Content-Type-Options", "nosniff");
139 }
140
141 Ok(response.body(axum::body::Body::from_stream(stream))?)
142}
143
144pub async fn get_file_list(
146 State(app): State<App>,
147 tn_id: TnId,
148 IdTag(tenant_id_tag): IdTag,
149 OptionalAuth(maybe_auth): OptionalAuth,
150 Query(mut opts): Query<meta_adapter::ListFileOptions>,
151 OptionalRequestId(req_id): OptionalRequestId,
152) -> ClResult<(StatusCode, Json<ApiResponse<Vec<meta_adapter::FileView>>>)> {
153 let (subject_id_tag, is_authenticated, subject_roles, scope) = match &maybe_auth {
155 Some(auth) => {
156 opts.user_id_tag = Some(auth.id_tag.to_string());
157 (auth.id_tag.as_ref(), true, &auth.roles[..], auth.scope.as_deref())
158 }
159 None => ("", false, &[][..], None),
160 };
161
162 if let Some(scope_fid) = scope.and_then(TokenScope::parse).and_then(|ts| match ts {
164 TokenScope::File { file_id, .. } => Some(file_id),
165 TokenScope::ApkgPublish => None,
166 }) {
167 opts.scope_file_id = Some(scope_fid);
168 }
169
170 let rels = app.meta_adapter.get_relationships(tn_id, &[subject_id_tag]).await?;
172 let (following, connected) = rels.get(subject_id_tag).copied().unwrap_or((false, false));
173 let is_real_auth = is_authenticated && !subject_id_tag.is_empty() && subject_id_tag != "guest";
174 let is_tenant = subject_id_tag == tenant_id_tag.as_ref();
175
176 let access_level = if is_tenant {
177 SubjectAccessLevel::Owner
178 } else if connected {
179 SubjectAccessLevel::Connected
180 } else if following {
181 SubjectAccessLevel::Follower
182 } else if is_real_auth {
183 SubjectAccessLevel::Verified
184 } else {
185 SubjectAccessLevel::Public
186 };
187 opts.visible_levels = access_level.visible_levels().map(<[char]>::to_vec);
188
189 let limit = opts.limit.unwrap_or(30) as usize;
190 let sort_field = opts.sort.as_deref().unwrap_or("created");
191
192 let files = app.meta_adapter.list_files(tn_id, &opts).await?;
193
194 let mut filtered = filter::compute_file_access_levels(
197 &app,
198 tn_id,
199 subject_id_tag,
200 is_authenticated,
201 &tenant_id_tag,
202 subject_roles,
203 files,
204 )
205 .await?;
206
207 let has_more = filtered.len() > limit;
209 if has_more {
210 filtered.truncate(limit);
211 }
212
213 let next_cursor = if has_more && !filtered.is_empty() {
215 let last = filtered.last().ok_or(Error::Internal("no last item".into()))?;
216 let sort_value = match sort_field {
217 "recent" => {
218 let ts = last
220 .user_data
221 .as_ref()
222 .and_then(|ud| ud.accessed_at)
223 .unwrap_or(last.created_at);
224 serde_json::Value::Number(ts.0.into())
225 }
226 "modified" => {
227 let ts = last
229 .user_data
230 .as_ref()
231 .and_then(|ud| ud.modified_at)
232 .unwrap_or(last.created_at);
233 serde_json::Value::Number(ts.0.into())
234 }
235 "name" => serde_json::Value::String(last.file_name.to_string()),
236 _ => serde_json::Value::Number(last.created_at.0.into()),
237 };
238 let cursor = types::CursorData::new(sort_field, sort_value, &last.file_id);
239 Some(cursor.encode())
240 } else {
241 None
242 };
243
244 let response = ApiResponse::with_cursor_pagination(filtered, next_cursor, has_more)
245 .with_req_id(req_id.unwrap_or_default());
246
247 Ok((StatusCode::OK, Json(response)))
248}
249
250pub async fn get_file_variant(
252 State(app): State<App>,
253 tn_id: TnId,
254 extract::Path(variant_id): extract::Path<String>,
255) -> ClResult<impl response::IntoResponse> {
256 let variant = app.meta_adapter.read_file_variant(tn_id, &variant_id).await?;
257 info!("variant: {:?}", variant);
258 let stream = app.blob_adapter.read_blob_stream(tn_id, &variant_id).await?;
259
260 serve_file(None, &variant, stream, app.opts.disable_cache)
261}
262
263#[derive(Debug, Clone, Default, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct GetFileVariantSelector {
266 pub variant: Option<String>,
267 pub min_x: Option<u32>,
268 pub min_y: Option<u32>,
269 pub min_res: Option<u32>, }
271
272pub async fn get_file_variant_file_id(
273 State(app): State<App>,
274 tn_id: TnId,
275 extract::Path(file_id): extract::Path<String>,
276 extract::Query(selector): extract::Query<GetFileVariantSelector>,
277) -> ClResult<impl response::IntoResponse> {
278 let mut variants = app
279 .meta_adapter
280 .list_file_variants(tn_id, meta_adapter::FileId::FileId(&file_id))
281 .await?;
282 variants.sort();
283 debug!("variants: {:?}", variants);
284
285 let variant = descriptor::get_best_file_variant(&variants, &selector)?;
286 let stream = app.blob_adapter.read_blob_stream(tn_id, &variant.variant_id).await?;
287 let descriptor = descriptor::get_file_descriptor(&variants);
288
289 serve_file(Some(&descriptor), variant, stream, app.opts.disable_cache)
290}
291
292pub async fn get_file_descriptor(
293 State(app): State<App>,
294 tn_id: TnId,
295 extract::Path(file_id): extract::Path<String>,
296 OptionalRequestId(req_id): OptionalRequestId,
297) -> ClResult<(StatusCode, Json<ApiResponse<String>>)> {
298 let mut variants = app
299 .meta_adapter
300 .list_file_variants(tn_id, meta_adapter::FileId::FileId(&file_id))
301 .await?;
302 variants.sort();
303
304 let descriptor = descriptor::get_file_descriptor(&variants);
305
306 let response = ApiResponse::new(descriptor).with_req_id(req_id.unwrap_or_default());
307
308 Ok((StatusCode::OK, Json(response)))
309}
310
311#[derive(Deserialize)]
312pub struct PostFileQuery {
313 #[serde(rename = "parentId")]
314 parent_id: Option<String>,
315 #[serde(rename = "rootId")]
316 root_id: Option<String>,
317 #[serde(rename = "createdAt")]
318 created_at: Option<Timestamp>,
319 tags: Option<String>,
320 visibility: Option<char>,
322}
323
324#[derive(Deserialize)]
325pub struct PostFileRequest {
326 #[serde(rename = "fileTp")]
327 file_tp: String, #[serde(rename = "contentType")]
329 content_type: Option<String>, #[serde(rename = "fileName")]
331 file_name: Option<String>,
332 #[serde(rename = "parentId")]
333 parent_id: Option<String>,
334 #[serde(rename = "rootId")]
336 root_id: Option<String>,
337 #[serde(rename = "createdAt")]
338 created_at: Option<Timestamp>,
339 tags: Option<String>,
340 visibility: Option<char>,
342}
343
344async fn handle_post_image(
345 app: &App,
346 tn_id: types::TnId,
347 f_id: u64,
348 _content_type: &str,
349 bytes: &[u8],
350 preset: &preset::FilePreset,
351) -> ClResult<serde_json::Value> {
352 let result = image::generate_image_variants(app, tn_id, f_id, bytes, preset).await?;
353
354 let mut data = json!({
355 "fileId": format!("@{}", f_id),
356 "dim": [result.dim.0, result.dim.1]
357 });
358 if let Some(thumb_id) = result.thumbnail_variant_id {
359 data["thumbnailVariantId"] = serde_json::Value::String(thumb_id);
360 }
361 Ok(data)
362}
363
364async fn handle_post_svg(
366 app: &App,
367 tn_id: types::TnId,
368 f_id: u64,
369 bytes: &[u8],
370 preset: &preset::FilePreset,
371) -> ClResult<serde_json::Value> {
372 let sanitized = svg::sanitize_svg(bytes)?;
374 info!("SVG sanitized: {} -> {} bytes", bytes.len(), sanitized.len());
375
376 let (orig_width, orig_height) = svg::parse_svg_dimensions(&sanitized)?;
378 info!("SVG dimensions: {}x{}", orig_width, orig_height);
379
380 let thumbnail_format_str = app
382 .settings
383 .get_string(tn_id, "file.thumbnail_format")
384 .await
385 .unwrap_or_else(|_| "webp".to_string());
386 let thumbnail_format: image::ImageFormat =
387 thumbnail_format_str.parse().unwrap_or(image::ImageFormat::Webp);
388
389 let sd_variant_id = if preset.store_original {
395 store::create_blob_buf(app, tn_id, &sanitized, blob_adapter::CreateBlobOptions::default())
396 .await?
397 } else {
398 hasher::hash("b", &sanitized)
399 };
400
401 app.meta_adapter
403 .create_file_variant(
404 tn_id,
405 f_id,
406 meta_adapter::FileVariant {
407 variant_id: sd_variant_id.as_ref(),
408 variant: "vis.sd",
409 format: "svg",
410 resolution: (orig_width, orig_height),
411 size: sanitized.len() as u64,
412 available: preset.store_original,
413 duration: None,
414 bitrate: None,
415 page_count: None,
416 },
417 )
418 .await?;
419
420 let thumbnail_variant = preset.thumbnail_variant.as_deref().unwrap_or("vis.tn");
422 let thumbnail_tier = preset::get_image_tier(thumbnail_variant);
423 let tn_format = thumbnail_tier.and_then(|t| t.format).unwrap_or(thumbnail_format);
424 let tn_max_dim = thumbnail_tier.map_or(256, |t| t.max_dim);
425
426 let resized_tn = svg::rasterize_svg_sync(&sanitized, tn_format, (tn_max_dim, tn_max_dim))?;
428
429 let thumbnail_variant_id = store::create_blob_buf(
430 app,
431 tn_id,
432 &resized_tn.bytes,
433 blob_adapter::CreateBlobOptions::default(),
434 )
435 .await?;
436
437 app.meta_adapter
438 .create_file_variant(
439 tn_id,
440 f_id,
441 meta_adapter::FileVariant {
442 variant_id: thumbnail_variant_id.as_ref(),
443 variant: thumbnail_variant,
444 format: tn_format.as_ref(),
445 resolution: (resized_tn.width, resized_tn.height),
446 size: resized_tn.bytes.len() as u64,
447 available: true,
448 duration: None,
449 bitrate: None,
450 page_count: None,
451 },
452 )
453 .await?;
454
455 info!(
456 "SVG thumbnail created: {}x{} ({} bytes)",
457 resized_tn.width,
458 resized_tn.height,
459 resized_tn.bytes.len()
460 );
461
462 app.scheduler
464 .task(FileIdGeneratorTask::new(tn_id, f_id))
465 .key(format!("{},{}", tn_id, f_id))
466 .schedule()
467 .await?;
468
469 Ok(json!({
470 "fileId": format!("@{}", f_id),
471 "thumbnailVariantId": thumbnail_variant_id,
472 "dim": [orig_width, orig_height]
473 }))
474}
475
476async fn handle_post_video_stream(
478 app: &App,
479 tn_id: types::TnId,
480 f_id: u64,
481 content_type: &str,
482 body: Body,
483 preset: &preset::FilePreset,
484) -> ClResult<serde_json::Value> {
485 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
487 let total_size = stream_body_to_file(body, &temp_path).await?;
488 info!("Video upload streamed to {:?}, size: {} bytes", temp_path, total_size);
489
490 let media_info = ffmpeg::FFmpeg::probe(&temp_path)
492 .map_err(|e| Error::Internal(format!("ffprobe failed: {}", e)))?;
493 let duration = media_info.duration;
494 let resolution = media_info.video_resolution().unwrap_or((0, 0));
495 info!("Video info: duration={:.2}s, resolution={}x{}", duration, resolution.0, resolution.1);
496
497 let max_quality_str = app
499 .settings
500 .get_string(tn_id, "file.max_generate_variant")
501 .await
502 .unwrap_or_else(|_| "hd".to_string());
503 let max_quality =
504 variant::parse_quality(&max_quality_str).unwrap_or(variant::VariantQuality::High);
505
506 if app.settings.get_bool(tn_id, "file.store_original_vid").await.unwrap_or(false) {
508 let orig_blob_id = store::create_blob_from_file(
509 app,
510 tn_id,
511 &temp_path,
512 blob_adapter::CreateBlobOptions::default(),
513 )
514 .await?;
515 app.meta_adapter
516 .create_file_variant(
517 tn_id,
518 f_id,
519 meta_adapter::FileVariant {
520 variant_id: &orig_blob_id,
521 variant: "orig",
522 format: format_from_content_type(content_type).unwrap_or("mp4"),
523 resolution,
524 size: total_size,
525 available: true,
526 duration: Some(duration),
527 bitrate: None,
528 page_count: None,
529 },
530 )
531 .await?;
532 }
533
534 let frame_path = app.opts.tmp_dir.join(format!("frame_{}.jpg", f_id));
536
537 let seek_time = if duration > 10.0 {
539 (duration * 0.1).max(3.0).min(duration - 1.0)
540 } else if duration > 1.0 {
541 duration / 2.0
542 } else {
543 0.0
544 };
545
546 ffmpeg::FFmpeg::extract_frame(&temp_path, &frame_path, seek_time)
548 .map_err(|e| Error::Internal(format!("thumbnail extraction failed: {}", e)))?;
549
550 let frame_bytes = tokio::fs::read(&frame_path).await?;
552
553 let thumbnail_result =
554 image::resize_image(app.clone(), frame_bytes, image::ImageFormat::Webp, (256, 256))
555 .await
556 .map_err(|e| Error::Internal(format!("thumbnail resize failed: {}", e)))?;
557
558 let thumbnail_variant_id = store::create_blob_buf(
560 app,
561 tn_id,
562 &thumbnail_result.bytes,
563 blob_adapter::CreateBlobOptions::default(),
564 )
565 .await?;
566
567 app.meta_adapter
569 .create_file_variant(
570 tn_id,
571 f_id,
572 meta_adapter::FileVariant {
573 variant_id: &thumbnail_variant_id,
574 variant: "vis.tn",
575 format: "webp",
576 resolution: (thumbnail_result.width, thumbnail_result.height),
577 size: thumbnail_result.bytes.len() as u64,
578 available: true,
579 duration: None,
580 bitrate: None,
581 page_count: None,
582 },
583 )
584 .await?;
585
586 info!(
587 "Video thumbnail extracted: {}x{} ({} bytes)",
588 thumbnail_result.width,
589 thumbnail_result.height,
590 thumbnail_result.bytes.len()
591 );
592
593 let mut task_ids = Vec::new();
595
596 for variant_name in &preset.image_variants {
598 if variant_name == "vis.tn" {
599 continue; }
601 if let Some(parsed) = variant::Variant::parse(variant_name) {
603 if parsed.quality > max_quality {
604 continue;
605 }
606 }
607 if let Some(tier) = get_image_tier(variant_name) {
608 let task = ImageResizerTask::new(
609 tn_id,
610 f_id,
611 frame_path.clone(),
612 variant_name.clone(),
613 image::ImageFormat::Webp,
614 (tier.max_dim, tier.max_dim),
615 );
616 task_ids.push(app.scheduler.add(task).await?);
617 }
618 }
619
620 for variant_name in &preset.video_variants {
622 if let Some(parsed) = variant::Variant::parse(variant_name) {
624 if parsed.quality > max_quality {
625 continue;
626 }
627 }
628 if let Some(tier) = get_video_tier(variant_name) {
629 let task = VideoTranscoderTask::new(
630 tn_id,
631 f_id,
632 temp_path.clone(),
633 variant_name.as_str(),
634 tier.max_dim,
635 tier.bitrate,
636 );
637 task_ids.push(app.scheduler.add(task).await?);
638 }
639 }
640
641 if preset.extract_audio {
643 for variant_name in &preset.audio_variants {
644 if let Some(parsed) = variant::Variant::parse(variant_name) {
646 if parsed.quality > max_quality {
647 continue;
648 }
649 }
650 if let Some(tier) = get_audio_tier(variant_name) {
651 let task = AudioExtractorTask::new(
652 tn_id,
653 f_id,
654 temp_path.clone(),
655 variant_name.as_str(),
656 tier.bitrate,
657 );
658 task_ids.push(app.scheduler.add(task).await?);
659 }
660 }
661 }
662
663 let mut builder = app
665 .scheduler
666 .task(FileIdGeneratorTask::new(tn_id, f_id))
667 .key(format!("{},{}", tn_id, f_id));
668 if !task_ids.is_empty() {
669 builder = builder.depend_on(task_ids);
670 }
671 builder.schedule().await?;
672
673 Ok(json!({
674 "fileId": format!("@{}", f_id),
675 "duration": duration,
676 "resolution": [resolution.0, resolution.1],
677 "thumbnailVariantId": thumbnail_variant_id
678 }))
679}
680
681async fn handle_post_audio_stream(
683 app: &App,
684 tn_id: types::TnId,
685 f_id: u64,
686 content_type: &str,
687 body: Body,
688 preset: &preset::FilePreset,
689) -> ClResult<serde_json::Value> {
690 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
692 let total_size = stream_body_to_file(body, &temp_path).await?;
693 info!("Audio upload streamed to {:?}, size: {} bytes", temp_path, total_size);
694
695 let media_info = ffmpeg::FFmpeg::probe(&temp_path)
697 .map_err(|e| Error::Internal(format!("ffprobe failed: {}", e)))?;
698 let duration = media_info.duration;
699 info!("Audio info: duration={:.2}s", duration);
700
701 let max_quality_str = app
703 .settings
704 .get_string(tn_id, "file.max_generate_variant")
705 .await
706 .unwrap_or_else(|_| "hd".to_string());
707 let max_quality =
708 variant::parse_quality(&max_quality_str).unwrap_or(variant::VariantQuality::High);
709
710 if app.settings.get_bool(tn_id, "file.store_original_aud").await.unwrap_or(false) {
712 let orig_blob_id = store::create_blob_from_file(
713 app,
714 tn_id,
715 &temp_path,
716 blob_adapter::CreateBlobOptions::default(),
717 )
718 .await?;
719 app.meta_adapter
720 .create_file_variant(
721 tn_id,
722 f_id,
723 meta_adapter::FileVariant {
724 variant_id: &orig_blob_id,
725 variant: "orig",
726 format: format_from_content_type(content_type).unwrap_or("mp3"),
727 resolution: (0, 0),
728 size: total_size,
729 available: true,
730 duration: Some(duration),
731 bitrate: None,
732 page_count: None,
733 },
734 )
735 .await?;
736 }
737
738 let mut task_ids = Vec::new();
740 for variant_name in &preset.audio_variants {
741 if let Some(parsed) = variant::Variant::parse(variant_name) {
743 if parsed.quality > max_quality {
744 continue;
745 }
746 }
747 if let Some(tier) = get_audio_tier(variant_name) {
748 let task = AudioExtractorTask::new(
749 tn_id,
750 f_id,
751 temp_path.clone(),
752 variant_name.as_str(),
753 tier.bitrate,
754 );
755 task_ids.push(app.scheduler.add(task).await?);
756 }
757 }
758
759 let mut builder = app
761 .scheduler
762 .task(FileIdGeneratorTask::new(tn_id, f_id))
763 .key(format!("{},{}", tn_id, f_id));
764 if !task_ids.is_empty() {
765 builder = builder.depend_on(task_ids);
766 }
767 builder.schedule().await?;
768
769 Ok(json!({
770 "fileId": format!("@{}", f_id),
771 "duration": duration
772 }))
773}
774
775async fn handle_post_pdf(
777 app: &App,
778 tn_id: types::TnId,
779 f_id: u64,
780 bytes: &[u8],
781) -> ClResult<serde_json::Value> {
782 let orig_blob_id =
784 store::create_blob_buf(app, tn_id, bytes, blob_adapter::CreateBlobOptions::default())
785 .await?;
786
787 let temp_path = app.opts.tmp_dir.join(format!("pdf_{}_{}", tn_id.0, f_id));
789 tokio::fs::write(&temp_path, bytes).await?;
790
791 let pdf_result = pdf::generate_pdf_thumbnail_variant(app, tn_id, f_id, &temp_path, 256).await?;
793
794 let _ = tokio::fs::remove_file(&temp_path).await;
796
797 app.meta_adapter
799 .create_file_variant(
800 tn_id,
801 f_id,
802 meta_adapter::FileVariant {
803 variant_id: &orig_blob_id,
804 variant: "orig",
805 format: "pdf",
806 resolution: (0, 0),
807 size: bytes.len() as u64,
808 available: true,
809 duration: None,
810 bitrate: None,
811 page_count: Some(pdf_result.page_count),
812 },
813 )
814 .await?;
815
816 app.scheduler
818 .task(FileIdGeneratorTask::new(tn_id, f_id))
819 .key(format!("{},{}", tn_id, f_id))
820 .schedule()
821 .await?;
822
823 Ok(json!({
824 "fileId": format!("@{}", f_id),
825 "thumbnailVariantId": pdf_result.variant_id
826 }))
827}
828
829async fn handle_post_raw_stream(
831 app: &App,
832 tn_id: types::TnId,
833 f_id: u64,
834 content_type: &str,
835 body: Body,
836) -> ClResult<serde_json::Value> {
837 let temp_path = app.opts.tmp_dir.join(format!("upload_{}_{}", tn_id.0, f_id));
839 let total_size = stream_body_to_file(body, &temp_path).await?;
840 info!("Raw upload streamed to {:?}, size: {} bytes", temp_path, total_size);
841
842 let orig_blob_id = store::create_blob_from_file(
844 app,
845 tn_id,
846 &temp_path,
847 blob_adapter::CreateBlobOptions::default(),
848 )
849 .await?;
850
851 let format = format_from_content_type(content_type).unwrap_or("bin");
853
854 app.meta_adapter
855 .create_file_variant(
856 tn_id,
857 f_id,
858 meta_adapter::FileVariant {
859 variant_id: &orig_blob_id,
860 variant: "orig",
861 format,
862 resolution: (0, 0),
863 size: total_size,
864 available: true,
865 duration: None,
866 bitrate: None,
867 page_count: None,
868 },
869 )
870 .await?;
871
872 let _ = tokio::fs::remove_file(&temp_path).await;
874
875 app.scheduler
877 .task(FileIdGeneratorTask::new(tn_id, f_id))
878 .key(format!("{},{}", tn_id, f_id))
879 .schedule()
880 .await?;
881
882 Ok(json!({"fileId": format!("@{}", f_id)}))
883}
884
885pub async fn post_file(
893 State(app): State<App>,
894 tn_id: TnId,
895 Auth(auth): Auth,
896 OptionalRequestId(req_id): OptionalRequestId,
897 extract::Json(req): extract::Json<PostFileRequest>,
898) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
899 info!("POST /api/files - Creating file with fileTp={}", req.file_tp);
900
901 file_access::check_scope_allows_create(auth.scope.as_deref(), req.root_id.as_deref())?;
903
904 let file_id = utils::random_id()?;
906
907 if let Some(ref root_id) = req.root_id {
909 let root_file =
910 app.meta_adapter.read_file(tn_id, root_id).await?.ok_or_else(|| {
911 Error::ValidationError(format!("root file '{}' not found", root_id))
912 })?;
913 if root_file.root_id.is_some() {
914 return Err(Error::ValidationError(
915 "root_id must reference a top-level file (not a file that itself has a root_id)"
916 .into(),
917 ));
918 }
919 }
920
921 let tenant_meta = app.meta_adapter.read_tenant(tn_id).await?;
923 let visibility = match req.visibility {
924 Some(v) => Some(v),
925 None if matches!(tenant_meta.typ, meta_adapter::ProfileType::Community) => Some('C'),
926 None => None,
927 };
928
929 let content_type = req.content_type.clone().unwrap_or_else(|| "application/json".to_string());
931 let _f_id = app
932 .meta_adapter
933 .create_file(
934 tn_id,
935 meta_adapter::CreateFile {
936 preset: Some("default".into()),
937 orig_variant_id: Some(file_id.clone().into()),
938 file_id: Some(file_id.clone().into()),
939 parent_id: req.parent_id.map(Into::into),
940 root_id: req.root_id.map(Into::into),
941 creator_tag: Some(auth.id_tag.clone()),
942 content_type: content_type.into(),
943 file_name: req.file_name.clone().unwrap_or_else(|| "file".into()).into(),
944 file_tp: Some(req.file_tp.clone().into()),
945 created_at: req.created_at,
946 tags: req.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
947 visibility,
948 ..Default::default()
949 },
950 )
951 .await?;
952
953 info!("Created file metadata for fileTp={} by {}", req.file_tp, auth.id_tag);
954
955 let data = json!({"fileId": file_id});
956
957 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
958
959 Ok((StatusCode::CREATED, Json(response)))
960}
961
962#[expect(clippy::too_many_arguments, reason = "file processing requires multiple parameters")]
963pub async fn post_file_blob(
964 State(app): State<App>,
965 tn_id: TnId,
966 Auth(auth): Auth,
967 extract::Path((preset_name, file_name)): extract::Path<(String, String)>,
968 query: Query<PostFileQuery>,
969 header: axum::http::HeaderMap,
970 OptionalRequestId(req_id): OptionalRequestId,
971 body: Body,
972) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
973 const BYTES_PER_MIB: usize = 1_048_576; const DEFAULT_MAX_SIZE_MIB: i64 = 50;
976
977 file_access::check_scope_allows_create(auth.scope.as_deref(), query.root_id.as_deref())?;
979
980 let content_type = header
981 .get(axum::http::header::CONTENT_TYPE)
982 .and_then(|v| v.to_str().ok())
983 .unwrap_or("application/octet-stream");
984 info!(
985 "post_file_blob: preset={}, content_type={}, root_id={:?}, parent_id={:?}",
986 preset_name, content_type, query.root_id, query.parent_id
987 );
988
989 let tenant_meta = app.meta_adapter.read_tenant(tn_id).await?;
991 let visibility = match query.visibility {
992 Some(v) => Some(v),
993 None if matches!(tenant_meta.typ, meta_adapter::ProfileType::Community) => Some('C'),
994 None => None,
995 };
996
997 if let Some(ref root_id) = query.root_id {
999 let root_file =
1000 app.meta_adapter.read_file(tn_id, root_id).await?.ok_or_else(|| {
1001 Error::ValidationError(format!("root file '{}' not found", root_id))
1002 })?;
1003 if root_file.root_id.is_some() {
1004 return Err(Error::ValidationError(
1005 "root_id must reference a top-level file (not a file that itself has a root_id)"
1006 .into(),
1007 ));
1008 }
1009 }
1010
1011 let preset = presets::get(&preset_name).unwrap_or_else(presets::default);
1013
1014 let media_class = VariantClass::from_content_type(content_type);
1016
1017 let media_class = match media_class {
1019 Some(class) if preset.allowed_media_classes.contains(&class) => class,
1020 Some(class) => {
1021 return Err(Error::ValidationError(format!(
1022 "preset '{}' does not allow {:?} uploads",
1023 preset.name, class
1024 )))
1025 }
1026 None if preset.allowed_media_classes.contains(&VariantClass::Raw) => VariantClass::Raw,
1027 None => return Err(Error::ValidationError("unsupported media type".into())),
1028 };
1029
1030 info!("Media class: {:?}", media_class);
1031
1032 let max_size_mib = app
1033 .settings
1034 .get_int(tn_id, "file.max_file_size_mb")
1035 .await
1036 .unwrap_or(DEFAULT_MAX_SIZE_MIB)
1037 .max(1); let max_size_bytes = usize::try_from(max_size_mib).unwrap_or(50) * BYTES_PER_MIB;
1040
1041 match media_class {
1043 VariantClass::Visual => {
1045 let bytes = to_bytes(body, max_size_bytes).await?;
1046 let orig_variant_id = hasher::hash("b", &bytes);
1047
1048 let is_svg = content_type == "image/svg+xml"
1050 || (content_type == "application/octet-stream" && svg::is_svg(&bytes));
1051
1052 let dim = if is_svg {
1054 svg::parse_svg_dimensions(&bytes)?
1055 } else {
1056 image::get_image_dimensions(&bytes).await?
1057 };
1058 info!("Image dimensions: {}/{} (SVG: {})", dim.0, dim.1, is_svg);
1059
1060 let f_id = app
1061 .meta_adapter
1062 .create_file(
1063 tn_id,
1064 meta_adapter::CreateFile {
1065 preset: Some(preset_name.clone().into()),
1066 orig_variant_id: Some(orig_variant_id),
1067 creator_tag: Some(auth.id_tag.clone()),
1068 content_type: if is_svg {
1069 "image/svg+xml".into()
1070 } else {
1071 content_type.into()
1072 },
1073 file_name: file_name.into(),
1074 file_tp: Some("BLOB".into()),
1075 created_at: query.created_at,
1076 tags: query.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
1077 x: Some(json!({ "dim": dim })),
1078 root_id: query.root_id.clone().map(Into::into),
1079 parent_id: query.parent_id.clone().map(Into::into),
1080 visibility,
1081 ..Default::default()
1082 },
1083 )
1084 .await?;
1085
1086 match f_id {
1087 meta_adapter::FileId::FId(f_id) => {
1088 let data = if is_svg {
1090 handle_post_svg(&app, tn_id, f_id, &bytes, &preset).await?
1091 } else {
1092 handle_post_image(&app, tn_id, f_id, content_type, &bytes, &preset).await?
1093 };
1094 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1095 Ok((StatusCode::CREATED, Json(response)))
1096 }
1097 meta_adapter::FileId::FileId(file_id) => {
1098 let data = json!({"fileId": file_id});
1099 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1100 Ok((StatusCode::CREATED, Json(response)))
1101 }
1102 }
1103 }
1104
1105 VariantClass::Document => {
1106 let bytes = to_bytes(body, max_size_bytes).await?;
1107 let orig_variant_id = hasher::hash("b", &bytes);
1108
1109 let f_id = app
1110 .meta_adapter
1111 .create_file(
1112 tn_id,
1113 meta_adapter::CreateFile {
1114 preset: Some(preset_name.clone().into()),
1115 orig_variant_id: Some(orig_variant_id),
1116 creator_tag: Some(auth.id_tag.clone()),
1117 content_type: content_type.into(),
1118 file_name: file_name.into(),
1119 file_tp: Some("BLOB".into()),
1120 created_at: query.created_at,
1121 tags: query.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
1122 root_id: query.root_id.clone().map(Into::into),
1123 parent_id: query.parent_id.clone().map(Into::into),
1124 visibility,
1125 ..Default::default()
1126 },
1127 )
1128 .await?;
1129
1130 match f_id {
1131 meta_adapter::FileId::FId(f_id) => {
1132 let data = handle_post_pdf(&app, tn_id, f_id, &bytes).await?;
1133 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1134 Ok((StatusCode::CREATED, Json(response)))
1135 }
1136 meta_adapter::FileId::FileId(file_id) => {
1137 let data = json!({"fileId": file_id});
1138 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1139 Ok((StatusCode::CREATED, Json(response)))
1140 }
1141 }
1142 }
1143
1144 VariantClass::Video => {
1146 let f_id = app
1147 .meta_adapter
1148 .create_file(
1149 tn_id,
1150 meta_adapter::CreateFile {
1151 preset: Some(preset_name.clone().into()),
1152 creator_tag: Some(auth.id_tag.clone()),
1153 content_type: content_type.into(),
1154 file_name: file_name.into(),
1155 file_tp: Some("BLOB".into()),
1156 created_at: query.created_at,
1157 tags: query.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
1158 root_id: query.root_id.clone().map(Into::into),
1159 parent_id: query.parent_id.clone().map(Into::into),
1160 visibility,
1161 ..Default::default()
1162 },
1163 )
1164 .await?;
1165
1166 match f_id {
1167 meta_adapter::FileId::FId(f_id) => {
1168 let data =
1169 handle_post_video_stream(&app, tn_id, f_id, content_type, body, &preset)
1170 .await?;
1171 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1172 Ok((StatusCode::CREATED, Json(response)))
1173 }
1174 meta_adapter::FileId::FileId(file_id) => {
1175 let data = json!({"fileId": file_id});
1176 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1177 Ok((StatusCode::CREATED, Json(response)))
1178 }
1179 }
1180 }
1181
1182 VariantClass::Audio => {
1183 let f_id = app
1184 .meta_adapter
1185 .create_file(
1186 tn_id,
1187 meta_adapter::CreateFile {
1188 preset: Some(preset_name.clone().into()),
1189 creator_tag: Some(auth.id_tag.clone()),
1190 content_type: content_type.into(),
1191 file_name: file_name.into(),
1192 file_tp: Some("BLOB".into()),
1193 created_at: query.created_at,
1194 tags: query.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
1195 root_id: query.root_id.clone().map(Into::into),
1196 parent_id: query.parent_id.clone().map(Into::into),
1197 visibility,
1198 ..Default::default()
1199 },
1200 )
1201 .await?;
1202
1203 match f_id {
1204 meta_adapter::FileId::FId(f_id) => {
1205 let data =
1206 handle_post_audio_stream(&app, tn_id, f_id, content_type, body, &preset)
1207 .await?;
1208 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1209 Ok((StatusCode::CREATED, Json(response)))
1210 }
1211 meta_adapter::FileId::FileId(file_id) => {
1212 let data = json!({"fileId": file_id});
1213 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1214 Ok((StatusCode::CREATED, Json(response)))
1215 }
1216 }
1217 }
1218
1219 VariantClass::Raw => {
1220 let f_id = app
1221 .meta_adapter
1222 .create_file(
1223 tn_id,
1224 meta_adapter::CreateFile {
1225 preset: Some(preset_name.clone().into()),
1226 creator_tag: Some(auth.id_tag.clone()),
1227 content_type: content_type.into(),
1228 file_name: file_name.into(),
1229 file_tp: Some("BLOB".into()),
1230 created_at: query.created_at,
1231 tags: query.tags.as_ref().map(|s| s.split(',').map(Into::into).collect()),
1232 root_id: query.root_id.clone().map(Into::into),
1233 parent_id: query.parent_id.clone().map(Into::into),
1234 visibility,
1235 ..Default::default()
1236 },
1237 )
1238 .await?;
1239
1240 match f_id {
1241 meta_adapter::FileId::FId(f_id) => {
1242 let data =
1243 handle_post_raw_stream(&app, tn_id, f_id, content_type, body).await?;
1244 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1245 Ok((StatusCode::CREATED, Json(response)))
1246 }
1247 meta_adapter::FileId::FileId(file_id) => {
1248 let data = json!({"fileId": file_id});
1249 let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
1250 Ok((StatusCode::CREATED, Json(response)))
1251 }
1252 }
1253 }
1254 }
1255}
1256
1257pub async fn get_file_metadata(
1259 State(app): State<App>,
1260 tn_id: TnId,
1261 extract::Path(file_id): extract::Path<String>,
1262 OptionalRequestId(req_id): OptionalRequestId,
1263) -> ClResult<(StatusCode, Json<ApiResponse<meta_adapter::FileView>>)> {
1264 let file = app.meta_adapter.read_file(tn_id, &file_id).await?.ok_or(Error::NotFound)?;
1265 Ok((StatusCode::OK, Json(ApiResponse::new(file).with_req_id(req_id.unwrap_or_default()))))
1266}
1267
1268