Skip to main content

cloudillo_file/
handler.rs

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
32// Utility functions //
33//*******************//
34pub fn format_from_content_type(content_type: &str) -> Option<&str> {
35	Some(match content_type {
36		// Image
37		"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
44		"video/mp4" | "video/quicktime" => "mp4",
45		"video/webm" => "webm",
46		"video/x-matroska" => "mkv",
47		"video/x-msvideo" => "avi",
48		// Audio
49		"audio/mpeg" => "mp3",
50		"audio/wav" => "wav",
51		"audio/ogg" => "ogg",
52		"audio/flac" => "flac",
53		"audio/aac" => "aac",
54		"audio/webm" => "weba",
55		// Document
56		"application/pdf" => "pdf",
57		_ => None?,
58	})
59}
60
61/// Stream request body directly to a temp file (for large uploads)
62async 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	// Add cache headers for content-addressed (immutable) files
102	if disable_cache {
103		response = response.header(axum::http::header::CACHE_CONTROL, "no-store, no-cache");
104	} else {
105		// Content-addressed files never change - use immutable caching
106		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	// Add CSP headers for SVG files to prevent script execution in federated content
116	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
125/// GET /api/files
126pub 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	// Set user_id_tag for user-specific data (pinned, starred, sorting by recent/modified)
135	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	// Filter files by visibility based on subject's access level
149	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	// Check if there are more results (we fetched limit+1)
161	let has_more = filtered.len() > limit;
162	if has_more {
163		filtered.truncate(limit);
164	}
165
166	// Build next cursor from last item
167	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				// Use user's accessed_at if available, otherwise created_at
172				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				// Use user's modified_at if available, otherwise created_at
181				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
203/// GET /api/files/variant/{variant_id}
204pub 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>, // min resolution in kpx
222}
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 level: P=Public, V=Verified, F=Follower, C=Connected, NULL=Direct
268	visibility: Option<char>,
269}
270
271#[derive(Deserialize)]
272pub struct PostFileRequest {
273	#[serde(rename = "fileTp")]
274	file_tp: String, // Required parameter
275	#[serde(rename = "contentType")]
276	content_type: Option<String>, // Optional, defaults to application/json
277	#[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 level: P=Public, V=Verified, F=Follower, C=Connected, NULL=Direct
284	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
304/// Handle SVG upload - sanitize, rasterize thumbnail, and store
305async 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	// 1. Sanitize SVG
313	let sanitized = svg::sanitize_svg(bytes)?;
314	info!("SVG sanitized: {} -> {} bytes", bytes.len(), sanitized.len());
315
316	// 2. Parse dimensions from sanitized SVG
317	let (orig_width, orig_height) = svg::parse_svg_dimensions(&sanitized)?;
318	info!("SVG dimensions: {}x{}", orig_width, orig_height);
319
320	// 3. Read format settings for thumbnail
321	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	// 4. Store sanitized SVG as vis.sd (SVG scales infinitely, no need for separate "orig")
330	// Note: We use vis.sd because:
331	// - Apps typically request vis.sd first, then fall back to vis.hd/orig
332	// - SVG is vector-based, any variant serves as highest quality
333	// - Database PRIMARY KEY (f_id, variant_id, tn_id) prevents two variants with same blob
334	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	// Create vis.sd variant with sanitized SVG
342	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	// 6. Determine thumbnail variant
361	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	// 7. Rasterize SVG for thumbnail (synchronous)
367	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	// 8. Schedule FileIdGeneratorTask (no additional variant tasks needed)
403	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
416/// Handle video upload - streams body to temp file, probes, creates transcode tasks
417async 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	// 1. Stream body directly to temp file (no memory buffering!)
426	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	// 2. Probe with FFmpeg to get duration/resolution
431	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	// Read max_generate_variant setting
438	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	// 3. Optionally store original variant (based on setting)
447	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	// 4. Extract thumbnail synchronously (like images)
475	let frame_path = app.opts.tmp_dir.join(format!("frame_{}.jpg", f_id));
476
477	// Calculate smart seek time (10% of duration, min 3s for long videos)
478	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	// Extract frame using FFmpeg
487	ffmpeg::FFmpeg::extract_frame(&temp_path, &frame_path, seek_time)
488		.map_err(|e| Error::Internal(format!("thumbnail extraction failed: {}", e)))?;
489
490	// Read frame and resize to thumbnail (keep frame file for other vis.* variants)
491	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	// Store thumbnail blob
499	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	// Create thumbnail variant record
508	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	// 5. Create tasks based on preset (async)
534	let mut task_ids = Vec::new();
535
536	// 5a. Create visual variants from extracted frame (sized frames approach)
537	for variant_name in &preset.image_variants {
538		if variant_name == "vis.tn" {
539			continue; // Already created thumbnail synchronously
540		}
541		// Skip variants exceeding max_generate_variant setting
542		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	// 5b. Create video transcode tasks
561	for variant_name in &preset.video_variants {
562		// Skip variants exceeding max_generate_variant setting
563		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	// 6. Optionally extract audio
582	if preset.extract_audio {
583		for variant_name in &preset.audio_variants {
584			// Skip variants exceeding max_generate_variant setting
585			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	// 7. Create FileIdGeneratorTask depending on transcode tasks
604	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
621/// Handle audio upload - streams body to temp file, probes, creates transcode tasks
622async 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	// 1. Stream body to temp file
631	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	// 2. Probe for duration
636	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	// Read max_generate_variant setting
642	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	// 3. Optionally store aud.orig
651	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	// 4. Create AudioExtractorTask for each variant
679	let mut task_ids = Vec::new();
680	for variant_name in &preset.audio_variants {
681		// Skip variants exceeding max_generate_variant setting
682		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	// 5. Create FileIdGeneratorTask
700	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
715/// Handle PDF upload - in-memory processing (PDFs are typically smaller)
716async 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	// 1. Store original blob as doc.orig (PDFs always need original)
723	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, // Will be updated by PdfProcessorTask
741			},
742		)
743		.await?;
744
745	// 2. Write to temp file for processing
746	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	// 3. Create PdfProcessorTask (extracts page count + thumbnail)
750	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	// 4. Create FileIdGeneratorTask
754	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
764/// Handle raw file upload - streams body to temp file, stores as-is
765async 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	// 1. Stream body to temp file
773	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	// 2. Store original blob as raw.orig
778	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	// Determine format from content-type or use generic extension
787	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	// 3. Clean up temp file
808	let _ = tokio::fs::remove_file(&temp_path).await;
809
810	// 4. Create FileIdGeneratorTask (no variants, just the original)
811	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
820/// POST /api/files - File creation for non-blob types (CRDT, RTDB, etc.)
821/// Accepts JSON body with metadata:
822/// {
823///   "fileTp": "CRDT" | "RTDB" | etc.,
824///   "createdAt": optional timestamp,
825///   "tags": optional comma-separated tags
826/// }
827pub 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	// Generate file_id
839	let file_id = utils::random_id()?;
840
841	// Default visibility to 'C' (Connected) for community tenants
842	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	// Create file metadata with specified fileTp
850	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	// Default visibility to 'C' (Connected) for community tenants
901	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	// 1. Get preset (or default)
909	let preset = presets::get(&preset_name).unwrap_or_else(presets::default);
910
911	// 2. Map content-type to media class
912	let media_class = VariantClass::from_content_type(content_type);
913
914	// 3. Validate against preset's allowed classes
915	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	// Get max file size from settings (in MiB, using binary units)
930	const BYTES_PER_MIB: usize = 1_048_576; // 1024 * 1024
931	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); // Ensure at least 1 MiB
939
940	let max_size_bytes = (max_size_mib as usize) * BYTES_PER_MIB;
941
942	// 4. Route to handler - some need bytes (in-memory), some need streaming Body
943	match media_class {
944		// In-memory processing (small files)
945		VariantClass::Visual => {
946			let bytes = to_bytes(body, max_size_bytes).await?;
947			let orig_variant_id = hasher::hash("b", &bytes);
948
949			// Detect if this is an SVG (check content-type or content itself)
950			let is_svg = content_type == "image/svg+xml"
951				|| (content_type == "application/octet-stream" && svg::is_svg(&bytes));
952
953			// Get dimensions - SVG uses different parsing
954			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					// Route to SVG or raster image handler
991					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		// Streaming to disk (large files) - create file metadata first, then stream
1049		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// vim: ts=4