Skip to main content

cloudillo_file/
management.rs

1//! File management (PATCH, DELETE, restore, duplicate) handlers
2
3use axum::{
4	extract::{Path, Query, State},
5	http::StatusCode,
6	Json,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11use crate::prelude::*;
12use cloudillo_core::abac::VisibilityLevel;
13use cloudillo_core::extract::{Auth, OptionalRequestId};
14use cloudillo_types::meta_adapter::{self, UpdateFileOptions};
15use cloudillo_types::types::{ApiResponse, Patch};
16use cloudillo_types::utils;
17
18/// Special folder ID for trash
19const TRASH_FOLDER_ID: &str = cloudillo_types::meta_adapter::TRASH_PARENT_ID;
20
21/// PATCH /file/:fileId - Update file metadata
22/// Uses UpdateFileOptions with Patch<> fields for proper null/undefined handling
23
24#[derive(Serialize)]
25pub struct PatchFileResponse {
26	#[serde(rename = "fileId")]
27	pub file_id: String,
28}
29
30pub async fn patch_file(
31	State(app): State<App>,
32	Auth(auth): Auth,
33	Path(file_id): Path<String>,
34	Json(opts): Json<UpdateFileOptions>,
35) -> ClResult<Json<PatchFileResponse>> {
36	app.meta_adapter.update_file_data(auth.tn_id, &file_id, &opts).await?;
37
38	info!("User {} patched file {}", auth.id_tag, file_id);
39
40	Ok(Json(PatchFileResponse { file_id }))
41}
42
43/// DELETE /file/:fileId - Move file to trash (soft delete)
44/// DELETE /file/:fileId?permanent=true - Permanently delete file (only from trash)
45#[derive(Debug, Deserialize)]
46pub struct DeleteFileQuery {
47	/// If true, permanently delete the file (only works for files already in trash)
48	#[serde(default)]
49	pub permanent: bool,
50}
51
52#[derive(Serialize)]
53pub struct DeleteFileResponse {
54	#[serde(rename = "fileId")]
55	pub file_id: String,
56	/// True if file was permanently deleted, false if moved to trash
57	pub permanent: bool,
58}
59
60pub async fn delete_file(
61	State(app): State<App>,
62	Auth(auth): Auth,
63	Path(file_id): Path<String>,
64	Query(query): Query<DeleteFileQuery>,
65) -> ClResult<Json<DeleteFileResponse>> {
66	// Check if file exists
67	let file = app.meta_adapter.read_file(auth.tn_id, &file_id).await?.ok_or_else(|| {
68		warn!("delete_file: File {} not found", file_id);
69		Error::NotFound
70	})?;
71
72	if query.permanent {
73		// Permanent delete - only allowed if file is in trash
74		if file.parent_id.as_deref() != Some(TRASH_FOLDER_ID) {
75			return Err(Error::ValidationError(
76				"Permanent delete only allowed for files in trash. Move to trash first.".into(),
77			));
78		}
79
80		// Actually delete from database
81		app.meta_adapter.delete_file(auth.tn_id, &file_id).await?;
82		info!("User {} permanently deleted file {}", auth.id_tag, file_id);
83
84		Ok(Json(DeleteFileResponse { file_id, permanent: true }))
85	} else {
86		// Soft delete - move to trash folder
87		app.meta_adapter
88			.update_file_data(
89				auth.tn_id,
90				&file_id,
91				&UpdateFileOptions {
92					parent_id: Patch::Value(TRASH_FOLDER_ID.to_string()),
93					..Default::default()
94				},
95			)
96			.await?;
97
98		info!("User {} moved file {} to trash", auth.id_tag, file_id);
99
100		Ok(Json(DeleteFileResponse { file_id, permanent: false }))
101	}
102}
103
104/// POST /file/:fileId/restore - Restore file from trash
105#[derive(Debug, Deserialize)]
106pub struct RestoreFileRequest {
107	/// Target folder to restore to. If null/missing, restores to root.
108	#[serde(rename = "parentId")]
109	pub parent_id: Option<String>,
110}
111
112#[derive(Serialize)]
113pub struct RestoreFileResponse {
114	#[serde(rename = "fileId")]
115	pub file_id: String,
116	#[serde(rename = "parentId")]
117	pub parent_id: Option<String>,
118}
119
120pub async fn restore_file(
121	State(app): State<App>,
122	Auth(auth): Auth,
123	Path(file_id): Path<String>,
124	Json(req): Json<RestoreFileRequest>,
125) -> ClResult<Json<RestoreFileResponse>> {
126	// Check if file exists and is in trash
127	let file = app.meta_adapter.read_file(auth.tn_id, &file_id).await?.ok_or_else(|| {
128		warn!("restore_file: File {} not found", file_id);
129		Error::NotFound
130	})?;
131
132	if file.parent_id.as_deref() != Some(TRASH_FOLDER_ID) {
133		return Err(Error::ValidationError("File is not in trash".into()));
134	}
135
136	// Move file to target folder (or root if not specified)
137	let target_parent_id = req.parent_id.clone();
138	app.meta_adapter
139		.update_file_data(
140			auth.tn_id,
141			&file_id,
142			&UpdateFileOptions {
143				parent_id: match &target_parent_id {
144					Some(id) => Patch::Value(id.clone()),
145					None => Patch::Null, // Move to root
146				},
147				..Default::default()
148			},
149		)
150		.await?;
151
152	info!("User {} restored file {} to {:?}", auth.id_tag, file_id, target_parent_id);
153
154	Ok(Json(RestoreFileResponse { file_id, parent_id: target_parent_id }))
155}
156
157/// DELETE /trash - Empty trash (permanently delete all files in trash)
158#[derive(Serialize)]
159pub struct EmptyTrashResponse {
160	/// Number of files permanently deleted
161	pub deleted_count: usize,
162}
163
164pub async fn empty_trash(
165	State(app): State<App>,
166	Auth(auth): Auth,
167) -> ClResult<Json<EmptyTrashResponse>> {
168	// List all files in trash
169	let trash_files = app
170		.meta_adapter
171		.list_files(
172			auth.tn_id,
173			&cloudillo_types::meta_adapter::ListFileOptions {
174				parent_id: Some(TRASH_FOLDER_ID.to_string()),
175				..Default::default()
176			},
177		)
178		.await?;
179
180	let mut deleted_count = 0;
181	for file in &trash_files {
182		app.meta_adapter.delete_file(auth.tn_id, &file.file_id).await?;
183		deleted_count += 1;
184	}
185
186	info!("User {} emptied trash ({} files deleted)", auth.id_tag, deleted_count);
187
188	Ok(Json(EmptyTrashResponse { deleted_count }))
189}
190
191/// PATCH /file/:fileId/user - Update user-specific file data (pinned/starred)
192#[derive(Debug, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct PatchFileUserDataRequest {
195	/// Pin file for quick access
196	pub pinned: Option<bool>,
197	/// Star/favorite file
198	pub starred: Option<bool>,
199}
200
201#[derive(Serialize)]
202#[serde(rename_all = "camelCase")]
203pub struct PatchFileUserDataResponse {
204	#[serde(rename = "fileId")]
205	pub file_id: String,
206	#[serde(
207		serialize_with = "cloudillo_types::types::serialize_timestamp_iso_opt",
208		skip_serializing_if = "Option::is_none"
209	)]
210	pub accessed_at: Option<cloudillo_types::types::Timestamp>,
211	#[serde(
212		serialize_with = "cloudillo_types::types::serialize_timestamp_iso_opt",
213		skip_serializing_if = "Option::is_none"
214	)]
215	pub modified_at: Option<cloudillo_types::types::Timestamp>,
216	pub pinned: bool,
217	pub starred: bool,
218}
219
220pub async fn patch_file_user_data(
221	State(app): State<App>,
222	Auth(auth): Auth,
223	Path(file_id): Path<String>,
224	Json(req): Json<PatchFileUserDataRequest>,
225) -> ClResult<Json<PatchFileUserDataResponse>> {
226	// Check if file exists
227	let _file = app.meta_adapter.read_file(auth.tn_id, &file_id).await?.ok_or_else(|| {
228		warn!("patch_file_user_data: File {} not found", file_id);
229		Error::NotFound
230	})?;
231
232	// Update user-specific data
233	let user_data = app
234		.meta_adapter
235		.update_file_user_data(auth.tn_id, &auth.id_tag, &file_id, req.pinned, req.starred)
236		.await?;
237
238	info!(
239		"User {} updated file {} user data: pinned={}, starred={}",
240		auth.id_tag, file_id, user_data.pinned, user_data.starred
241	);
242
243	Ok(Json(PatchFileUserDataResponse {
244		file_id,
245		accessed_at: user_data.accessed_at,
246		modified_at: user_data.modified_at,
247		pinned: user_data.pinned,
248		starred: user_data.starred,
249	}))
250}
251
252/// POST /api/files/:fileId/duplicate - Duplicate a CRDT or RTDB file
253#[derive(Debug, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct DuplicateFileRequest {
256	pub file_name: Option<String>,
257	pub parent_id: Option<String>,
258}
259
260pub async fn duplicate_file(
261	State(app): State<App>,
262	tn_id: TnId,
263	Auth(auth): Auth,
264	Path(file_id): Path<String>,
265	OptionalRequestId(req_id): OptionalRequestId,
266	Json(req): Json<DuplicateFileRequest>,
267) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
268	// 1. Read source file metadata
269	let file = app.meta_adapter.read_file(auth.tn_id, &file_id).await?.ok_or_else(|| {
270		warn!("duplicate_file: File {} not found", file_id);
271		Error::NotFound
272	})?;
273
274	// 2. Validate file type
275	let file_tp = file.file_tp.as_deref().unwrap_or("BLOB");
276	if file_tp != "CRDT" && file_tp != "RTDB" {
277		return Err(Error::ValidationError(format!(
278			"Only CRDT and RTDB files can be duplicated, got '{}'",
279			file_tp
280		)));
281	}
282
283	// 3. Generate new file_id
284	let new_file_id = utils::random_id()?;
285
286	// 4. Determine filename
287	let new_file_name = req.file_name.unwrap_or_else(|| format!("Copy of {}", file.file_name));
288
289	// 5. Copy content based on type
290	match file_tp {
291		"CRDT" => {
292			super::duplicate::duplicate_crdt_content(&app, tn_id, &file_id, &new_file_id).await?;
293		}
294		"RTDB" => {
295			super::duplicate::duplicate_rtdb_content(&app, tn_id, &file_id, &new_file_id).await?;
296		}
297		_ => {
298			return Err(Error::ValidationError(format!(
299				"Unsupported file type for duplication: '{}'",
300				file_tp
301			)));
302		}
303	}
304
305	// 6. Create file metadata for the duplicate
306	let parent_id = req.parent_id.map(Box::from).or(file.parent_id);
307	let _f_id = app
308		.meta_adapter
309		.create_file(
310			tn_id,
311			meta_adapter::CreateFile {
312				preset: file.preset,
313				orig_variant_id: Some(new_file_id.clone().into()),
314				file_id: Some(new_file_id.clone().into()),
315				parent_id,
316				owner_tag: None,
317				creator_tag: Some(auth.id_tag.clone()),
318				content_type: file.content_type.unwrap_or_else(|| "application/json".into()),
319				file_name: new_file_name.into(),
320				file_tp: file.file_tp,
321				created_at: None,
322				tags: file.tags,
323				x: file.x,
324				visibility: file.visibility,
325				status: None,
326			},
327		)
328		.await?;
329
330	info!("User {} duplicated file {} -> {}", auth.id_tag, file_id, new_file_id);
331
332	let data = json!({"fileId": new_file_id});
333	let response = ApiResponse::new(data).with_req_id(req_id.unwrap_or_default());
334	Ok((StatusCode::CREATED, Json(response)))
335}
336
337/// Upgrade file visibility to match target visibility (only if more permissive)
338///
339/// This function is used when attaching files to posts. If a file has more
340/// restrictive visibility than the post, we upgrade the file's visibility
341/// so recipients can access it.
342///
343/// Returns true if upgrade was performed, false if no change needed.
344pub async fn upgrade_file_visibility(
345	app: &App,
346	tn_id: TnId,
347	file_id: &str,
348	target_visibility: Option<char>,
349) -> ClResult<bool> {
350	// Get current file data
351	let file = app.meta_adapter.read_file(tn_id, file_id).await?.ok_or_else(|| {
352		warn!("upgrade_file_visibility: File {} not found", file_id);
353		Error::NotFound
354	})?;
355
356	let current = VisibilityLevel::from_char(file.visibility);
357	let target = VisibilityLevel::from_char(target_visibility);
358
359	// VisibilityLevel ordering: Public < Verified < ... < Connected < Direct
360	// Smaller value = more permissive
361	// Only upgrade if target is MORE permissive (smaller Ord value)
362	if target < current {
363		info!("Upgrading file {} visibility from {:?} to {:?}", file_id, current, target);
364
365		app.meta_adapter
366			.update_file_data(
367				tn_id,
368				file_id,
369				&UpdateFileOptions {
370					visibility: Patch::Value(target_visibility.unwrap_or('F')),
371					..Default::default()
372				},
373			)
374			.await?;
375
376		Ok(true)
377	} else {
378		debug!(
379			"File {} visibility {:?} already meets or exceeds target {:?}",
380			file_id, current, target
381		);
382		Ok(false)
383	}
384}
385
386// vim: ts=4