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,
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
34// Utility functions //
35//*******************//
36pub fn format_from_content_type(content_type: &str) -> Option<&str> {
37	Some(match content_type {
38		// Image
39		"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
46		"video/mp4" | "video/quicktime" => "mp4",
47		"video/webm" => "webm",
48		"video/x-matroska" => "mkv",
49		"video/x-msvideo" => "avi",
50		// Audio
51		"audio/mpeg" => "mp3",
52		"audio/wav" => "wav",
53		"audio/ogg" => "ogg",
54		"audio/flac" => "flac",
55		"audio/aac" => "aac",
56		"audio/webm" => "weba",
57		// Document
58		"application/pdf" => "pdf",
59		_ => None?,
60	})
61}
62
63/// Stream request body directly to a temp file (for large uploads)
64async 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		// Image
84		"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		// Video
91		"mp4" => "video/mp4",
92		"webm" => "video/webm",
93		"mkv" => "video/x-matroska",
94		"avi" => "video/x-msvideo",
95		// Audio
96		"mp3" => "audio/mpeg",
97		"wav" => "audio/wav",
98		"ogg" => "audio/ogg",
99		"flac" => "audio/flac",
100		"aac" => "audio/aac",
101		"weba" => "audio/webm",
102		// Document
103		"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	// Add cache headers for content-addressed (immutable) files
121	if disable_cache {
122		response = response.header(axum::http::header::CACHE_CONTROL, "no-store, no-cache");
123	} else {
124		// Content-addressed files never change - use immutable caching
125		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	// Add CSP headers for SVG files to prevent script execution in federated content
135	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
144/// GET /api/files
145pub 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	// Set user_id_tag for user-specific data (pinned, starred, sorting by recent/modified)
154	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	// For scoped tokens, push scope constraint into the DB query
163	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	// Push visibility filtering into SQL for correct pagination
171	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	// Compute access_level (Read/Write) for each file
195	// (visibility is already filtered at SQL level via visible_levels)
196	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	// Check if there are more results (we fetched limit+1)
208	let has_more = filtered.len() > limit;
209	if has_more {
210		filtered.truncate(limit);
211	}
212
213	// Build next cursor from last item
214	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				// Use user's accessed_at if available, otherwise created_at
219				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				// Use user's modified_at if available, otherwise created_at
228				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
250/// GET /api/files/variant/{variant_id}
251pub 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>, // min resolution in kpx
270}
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 level: P=Public, V=Verified, F=Follower, C=Connected, NULL=Direct
321	visibility: Option<char>,
322}
323
324#[derive(Deserialize)]
325pub struct PostFileRequest {
326	#[serde(rename = "fileTp")]
327	file_tp: String, // Required parameter
328	#[serde(rename = "contentType")]
329	content_type: Option<String>, // Optional, defaults to application/json
330	#[serde(rename = "fileName")]
331	file_name: Option<String>,
332	#[serde(rename = "parentId")]
333	parent_id: Option<String>,
334	/// Document tree root file_id (makes this a child in a document tree)
335	#[serde(rename = "rootId")]
336	root_id: Option<String>,
337	#[serde(rename = "createdAt")]
338	created_at: Option<Timestamp>,
339	tags: Option<String>,
340	/// Visibility level: P=Public, V=Verified, F=Follower, C=Connected, NULL=Direct
341	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
364/// Handle SVG upload - sanitize, rasterize thumbnail, and store
365async 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	// 1. Sanitize SVG
373	let sanitized = svg::sanitize_svg(bytes)?;
374	info!("SVG sanitized: {} -> {} bytes", bytes.len(), sanitized.len());
375
376	// 2. Parse dimensions from sanitized SVG
377	let (orig_width, orig_height) = svg::parse_svg_dimensions(&sanitized)?;
378	info!("SVG dimensions: {}x{}", orig_width, orig_height);
379
380	// 3. Read format settings for thumbnail
381	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	// 4. Store sanitized SVG as vis.sd (SVG scales infinitely, no need for separate "orig")
390	// Note: We use vis.sd because:
391	// - Apps typically request vis.sd first, then fall back to vis.hd/orig
392	// - SVG is vector-based, any variant serves as highest quality
393	// - Database PRIMARY KEY (f_id, variant_id, tn_id) prevents two variants with same blob
394	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	// Create vis.sd variant with sanitized SVG
402	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	// 6. Determine thumbnail variant
421	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	// 7. Rasterize SVG for thumbnail (synchronous)
427	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	// 8. Schedule FileIdGeneratorTask (no additional variant tasks needed)
463	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
476/// Handle video upload - streams body to temp file, probes, creates transcode tasks
477async 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	// 1. Stream body directly to temp file (no memory buffering!)
486	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	// 2. Probe with FFmpeg to get duration/resolution
491	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	// Read max_generate_variant setting
498	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	// 3. Optionally store original variant (based on setting)
507	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	// 4. Extract thumbnail synchronously (like images)
535	let frame_path = app.opts.tmp_dir.join(format!("frame_{}.jpg", f_id));
536
537	// Calculate smart seek time (10% of duration, min 3s for long videos)
538	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	// Extract frame using FFmpeg
547	ffmpeg::FFmpeg::extract_frame(&temp_path, &frame_path, seek_time)
548		.map_err(|e| Error::Internal(format!("thumbnail extraction failed: {}", e)))?;
549
550	// Read frame and resize to thumbnail (keep frame file for other vis.* variants)
551	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	// Store thumbnail blob
559	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	// Create thumbnail variant record
568	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	// 5. Create tasks based on preset (async)
594	let mut task_ids = Vec::new();
595
596	// 5a. Create visual variants from extracted frame (sized frames approach)
597	for variant_name in &preset.image_variants {
598		if variant_name == "vis.tn" {
599			continue; // Already created thumbnail synchronously
600		}
601		// Skip variants exceeding max_generate_variant setting
602		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	// 5b. Create video transcode tasks
621	for variant_name in &preset.video_variants {
622		// Skip variants exceeding max_generate_variant setting
623		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	// 6. Optionally extract audio
642	if preset.extract_audio {
643		for variant_name in &preset.audio_variants {
644			// Skip variants exceeding max_generate_variant setting
645			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	// 7. Create FileIdGeneratorTask depending on transcode tasks
664	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
681/// Handle audio upload - streams body to temp file, probes, creates transcode tasks
682async 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	// 1. Stream body to temp file
691	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	// 2. Probe for duration
696	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	// Read max_generate_variant setting
702	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	// 3. Optionally store aud.orig
711	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	// 4. Create AudioExtractorTask for each variant
739	let mut task_ids = Vec::new();
740	for variant_name in &preset.audio_variants {
741		// Skip variants exceeding max_generate_variant setting
742		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	// 5. Create FileIdGeneratorTask
760	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
775/// Handle PDF upload - in-memory processing (PDFs are typically smaller)
776async 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	// 1. Store original blob
783	let orig_blob_id =
784		store::create_blob_buf(app, tn_id, bytes, blob_adapter::CreateBlobOptions::default())
785			.await?;
786
787	// 2. Write to temp file for thumbnail generation
788	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	// 3. Generate thumbnail synchronously (so vis.tn is available immediately)
792	let pdf_result = pdf::generate_pdf_thumbnail_variant(app, tn_id, f_id, &temp_path, 256).await?;
793
794	// 4. Clean up temp file
795	let _ = tokio::fs::remove_file(&temp_path).await;
796
797	// 5. Create doc.orig variant with page count (now known from PDF info)
798	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	// 6. Create FileIdGeneratorTask (no dependencies needed)
817	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
829/// Handle raw file upload - streams body to temp file, stores as-is
830async 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	// 1. Stream body to temp file
838	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	// 2. Store original blob as raw.orig
843	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	// Determine format from content-type or use generic extension
852	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	// 3. Clean up temp file
873	let _ = tokio::fs::remove_file(&temp_path).await;
874
875	// 4. Create FileIdGeneratorTask (no variants, just the original)
876	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
885/// POST /api/files - File creation for non-blob types (CRDT, RTDB, etc.)
886/// Accepts JSON body with metadata:
887/// {
888///   "fileTp": "CRDT" | "RTDB" | etc.,
889///   "createdAt": optional timestamp,
890///   "tags": optional comma-separated tags
891/// }
892pub 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	// Scope check: scoped tokens can only create children under the scoped root
902	file_access::check_scope_allows_create(auth.scope.as_deref(), req.root_id.as_deref())?;
903
904	// Generate file_id
905	let file_id = utils::random_id()?;
906
907	// Validate root_id if provided - the root file must exist and be a top-level file
908	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	// Default visibility to 'C' (Connected) for community tenants
922	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	// Create file metadata with specified fileTp
930	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	// Max file size constants (in MiB, using binary units)
974	const BYTES_PER_MIB: usize = 1_048_576; // 1024 * 1024
975	const DEFAULT_MAX_SIZE_MIB: i64 = 50;
976
977	// Scope check: scoped tokens can only create children under the scoped root
978	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	// Default visibility to 'C' (Connected) for community tenants
990	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	// Validate root_id if provided - the root file must exist and be a top-level file
998	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	// 1. Get preset (or default)
1012	let preset = presets::get(&preset_name).unwrap_or_else(presets::default);
1013
1014	// 2. Map content-type to media class
1015	let media_class = VariantClass::from_content_type(content_type);
1016
1017	// 3. Validate against preset's allowed classes
1018	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); // Ensure at least 1 MiB
1038
1039	let max_size_bytes = usize::try_from(max_size_mib).unwrap_or(50) * BYTES_PER_MIB;
1040
1041	// 4. Route to handler - some need bytes (in-memory), some need streaming Body
1042	match media_class {
1043		// In-memory processing (small files)
1044		VariantClass::Visual => {
1045			let bytes = to_bytes(body, max_size_bytes).await?;
1046			let orig_variant_id = hasher::hash("b", &bytes);
1047
1048			// Detect if this is an SVG (check content-type or content itself)
1049			let is_svg = content_type == "image/svg+xml"
1050				|| (content_type == "application/octet-stream" && svg::is_svg(&bytes));
1051
1052			// Get dimensions - SVG uses different parsing
1053			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					// Route to SVG or raster image handler
1089					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		// Streaming to disk (large files) - create file metadata first, then stream
1145		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
1257/// GET /api/files/{file_id}/metadata
1258pub 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// vim: ts=4