1use 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
18const TRASH_FOLDER_ID: &str = cloudillo_types::meta_adapter::TRASH_PARENT_ID;
20
21#[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#[derive(Debug, Deserialize)]
46pub struct DeleteFileQuery {
47 #[serde(default)]
49 pub permanent: bool,
50}
51
52#[derive(Serialize)]
53pub struct DeleteFileResponse {
54 #[serde(rename = "fileId")]
55 pub file_id: String,
56 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 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 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 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 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#[derive(Debug, Deserialize)]
106pub struct RestoreFileRequest {
107 #[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 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 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, },
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#[derive(Serialize)]
159pub struct EmptyTrashResponse {
160 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 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#[derive(Debug, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct PatchFileUserDataRequest {
195 pub pinned: Option<bool>,
197 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 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 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#[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 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 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 let new_file_id = utils::random_id()?;
285
286 let new_file_name = req.file_name.unwrap_or_else(|| format!("Copy of {}", file.file_name));
288
289 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 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
337pub 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 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 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