files.rs
⎇
Raw
1//! File operations API.
2//!
3//! All operations live on the same URL shape as the listing, distinguished by
4//! method (and content type for POST):
5//!
6//! - `GET /api/files/{root_id}` and `/api/files/{root_id}/{*path}` — list
7//! - `DELETE /api/files/{root_id}/{*path}` — delete a file or folder
8//! - `POST /api/files/{root_id}/{*path}` — create a folder (no body)
9//! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy
10//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
11
12use std::path::Component;
13use std::sync::Arc;
14
15use axum::extract::{Path as AxumPath, State};
16use axum::http::{header, StatusCode};
17use axum::Json;
18use multer::Multipart;
19use serde::Deserialize;
20use tokio::io::AsyncWriteExt;
21
22use crate::api::common::AuthUser;
23use crate::db::RootRow;
24use crate::error::{ApiError, AppState};
25use crate::fs;
26
27// ---------------------------------------------------------------------------
28// Bodies / query params
29// ---------------------------------------------------------------------------
30
31#[derive(Deserialize)]
32pub struct MutationBody {
33 pub op: String, // "rename" | "move" | "copy"
34 #[serde(default)]
35 pub new_name: Option<String>,
36 #[serde(default)]
37 pub dst_root_id: Option<i64>,
38 #[serde(default)]
39 pub dst: Option<String>,
40 #[serde(default)]
41 pub overwrite: bool,
42}
43
44// ---------------------------------------------------------------------------
45// Listing
46// ---------------------------------------------------------------------------
47
48/// GET /api/files/{root_id}/{*path}
49pub async fn list(
50 State(state): State<Arc<AppState>>,
51 auth: AuthUser,
52 path: AxumPath<(i64, String)>,
53) -> Result<Json<serde_json::Value>, ApiError> {
54 let (root_id, req_rel) = path.0;
55 list_inner(state, auth, root_id, req_rel).await
56}
57
58/// GET /api/files/{root_id} — list the root directory itself.
59pub async fn list_root(
60 State(state): State<Arc<AppState>>,
61 auth: AuthUser,
62 path: AxumPath<i64>,
63) -> Result<Json<serde_json::Value>, ApiError> {
64 list_inner(state, auth, path.0, String::new()).await
65}
66
67async fn list_inner(
68 state: Arc<AppState>,
69 auth: AuthUser,
70 root_id: i64,
71 req_rel: String,
72) -> Result<Json<serde_json::Value>, ApiError> {
73 let root = find_root(&auth.roots, root_id)?;
74 let server_root = state.root.clone();
75 let root_rel = root.path.clone();
76 let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
77 .await
78 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
79
80 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
81 .await
82 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
83
84 Ok(Json(serde_json::json!({ "entries": entries })))
85}
86
87// ---------------------------------------------------------------------------
88// POST dispatch: mkdir | rename/move/copy | upload
89// ---------------------------------------------------------------------------
90
91/// POST /api/files/{root_id} — top-level operations (upload into the root
92/// directory). The bare root never targets a specific item, so JSON ops with
93/// a missing folder name are rejected by the individual handlers.
94pub async fn dispatch_root(
95 State(state): State<Arc<AppState>>,
96 auth: AuthUser,
97 path: AxumPath<i64>,
98 headers: axum::http::HeaderMap,
99 req: axum::http::Request<axum::body::Body>,
100) -> Result<Json<serde_json::Value>, ApiError> {
101 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
102}
103
104/// POST /api/files/{root_id}/{*path}
105pub async fn dispatch(
106 State(state): State<Arc<AppState>>,
107 auth: AuthUser,
108 path: AxumPath<(i64, String)>,
109 headers: axum::http::HeaderMap,
110 req: axum::http::Request<axum::body::Body>,
111) -> Result<Json<serde_json::Value>, ApiError> {
112 let (root_id, req_rel) = path.0;
113 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
114}
115
116async fn dispatch_inner(
117 state: Arc<AppState>,
118 auth: AuthUser,
119 root_id: i64,
120 req_rel: String,
121 headers: axum::http::HeaderMap,
122 req: axum::http::Request<axum::body::Body>,
123) -> Result<Json<serde_json::Value>, ApiError> {
124 let ct = headers
125 .get(header::CONTENT_TYPE)
126 .and_then(|v| v.to_str().ok())
127 .unwrap_or("");
128
129 if ct.starts_with("multipart/form-data") {
130 return upload(state, auth, root_id, req_rel, req).await;
131 }
132 if ct.starts_with("application/json") {
133 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
134 .await
135 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
136 let body: MutationBody = axum::Json::from_bytes(&bytes)
137 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
138 .0;
139 return mutation(state, auth, root_id, req_rel, body).await;
140 }
141 mkdir(state, auth, root_id, req_rel).await
142}
143
144// ---------------------------------------------------------------------------
145// mkdir
146// ---------------------------------------------------------------------------
147
148async fn mkdir(
149 state: Arc<AppState>,
150 auth: AuthUser,
151 root_id: i64,
152 req_rel: String,
153) -> Result<Json<serde_json::Value>, ApiError> {
154 let root = require_rw_root(&auth.roots, root_id)?;
155 if req_rel.trim().is_empty() {
156 return Err(ApiError::new(
157 StatusCode::BAD_REQUEST,
158 "a folder name is required",
159 ));
160 }
161 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
162 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
163 .await
164 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
165 Ok(Json(serde_json::json!({ "ok": true })))
166}
167
168// ---------------------------------------------------------------------------
169// rename / move / copy
170// ---------------------------------------------------------------------------
171
172async fn mutation(
173 state: Arc<AppState>,
174 auth: AuthUser,
175 root_id: i64,
176 req_rel: String,
177 body: MutationBody,
178) -> Result<Json<serde_json::Value>, ApiError> {
179 match body.op.as_str() {
180 "rename" => {
181 let new_name = body
182 .new_name
183 .as_deref()
184 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
185 .to_string();
186 let root = require_rw_root(&auth.roots, root_id)?;
187 let (server_root, root_rel, rel, overwrite) =
188 (state.root.clone(), root.path.clone(), req_rel, body.overwrite);
189 tokio::task::spawn_blocking(move || fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite))
190 .await
191 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
192 Ok(Json(serde_json::json!({ "ok": true })))
193 }
194 "move" | "copy" => {
195 let dst_root_id = body
196 .dst_root_id
197 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
198 let dst = body
199 .dst
200 .clone()
201 .unwrap_or_default();
202 // Moving or copying out of a folder requires rw there; copying
203 // *from* a read-only root is fine.
204 let src_root = if body.op == "move" {
205 require_rw_root(&auth.roots, root_id)?
206 } else {
207 find_root(&auth.roots, root_id)?
208 };
209 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
210 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
211 state.root.clone(),
212 src_root.path.clone(),
213 dst_root.path.clone(),
214 dst,
215 req_rel,
216 body.overwrite,
217 );
218 let op_is_move = body.op == "move";
219 tokio::task::spawn_blocking(move || {
220 if op_is_move {
221 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
222 } else {
223 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
224 }
225 })
226 .await
227 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
228 Ok(Json(serde_json::json!({ "ok": true })))
229 }
230 _ => Err(ApiError::new(
231 StatusCode::BAD_REQUEST,
232 "unknown op (expected rename, move or copy)",
233 )),
234 }
235}
236
237// ---------------------------------------------------------------------------
238// DELETE
239// ---------------------------------------------------------------------------
240
241pub async fn delete(
242 State(state): State<Arc<AppState>>,
243 auth: AuthUser,
244 path: AxumPath<(i64, String)>,
245) -> Result<Json<serde_json::Value>, ApiError> {
246 let (root_id, req_rel) = path.0;
247 let root = require_rw_root(&auth.roots, root_id)?;
248 if req_rel.trim().is_empty() {
249 return Err(ApiError::new(
250 StatusCode::BAD_REQUEST,
251 "a path inside the folder is required",
252 ));
253 }
254 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
255 let is_dir = tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
256 .await
257 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
258 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
259}
260
261// ---------------------------------------------------------------------------
262// Upload (multipart)
263// ---------------------------------------------------------------------------
264
265async fn upload(
266 state: Arc<AppState>,
267 auth: AuthUser,
268 root_id: i64,
269 req_rel: String,
270 req: axum::http::Request<axum::body::Body>,
271) -> Result<Json<serde_json::Value>, ApiError> {
272 let root = require_rw_root(&auth.roots, root_id)?;
273 let base = {
274 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
275 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
276 .await
277 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
278 };
279 let boundary = req
280 .headers()
281 .get(header::CONTENT_TYPE)
282 .and_then(|v| v.to_str().ok())
283 .and_then(parse_boundary)
284 .ok_or_else(|| {
285 ApiError::new(
286 StatusCode::BAD_REQUEST,
287 "expected multipart/form-data with a boundary",
288 )
289 })?;
290 let overwrite = parse_overwrite(req.uri());
291
292 let stream = req.into_body().into_data_stream();
293 let mut multipart = Multipart::new(stream, boundary);
294 let mut uploaded: usize = 0;
295 let mut skipped: Vec<String> = Vec::new();
296
297 while let Some(mut field) = multipart
298 .next_field()
299 .await
300 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
301 {
302 let part_name = field
303 .name()
304 .filter(|n| !n.is_empty())
305 .or_else(|| field.file_name())
306 .map(str::to_string)
307 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
308
309 validate_rel_path(&part_name)?;
310
311 let target = base.join(&part_name);
312 let parent = target
313 .parent()
314 .filter(|p| !p.as_os_str().is_empty())
315 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
316 if !parent.is_dir() {
317 let p = parent.to_path_buf();
318 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
319 .await
320 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
321 }
322
323 if target.exists() && !overwrite {
324 // Drain this part and report it as a conflict at the end.
325 while let Some(_chunk) = field
326 .chunk()
327 .await
328 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
329 {}
330 skipped.push(part_name);
331 continue;
332 }
333 if target.exists() {
334 if target.is_dir() {
335 return Err(ApiError::new(
336 StatusCode::CONFLICT,
337 "a folder with this name already exists",
338 ));
339 }
340 tokio::fs::remove_file(&target)
341 .await
342 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
343 }
344
345 // Stream to a temp file in the same directory, then rename into place.
346 let suffix = crate::auth::random_token();
347 let tmp = parent.join(format!(".upload-{suffix}"));
348 let mut tmp_file = tokio::fs::File::create(&tmp)
349 .await
350 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
351 let write_failed = loop {
352 match field
353 .chunk()
354 .await
355 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
356 {
357 Ok(Some(chunk)) => {
358 if let Err(e) = tmp_file.write_all(&chunk).await {
359 tracing::warn!(error = %e, "write failed during upload");
360 break true;
361 }
362 }
363 Ok(None) => break false,
364 Err(e) => return Err(e),
365 }
366 };
367 if write_failed {
368 let _ = tokio::fs::remove_file(&tmp).await;
369 return Err(ApiError::new(
370 StatusCode::INTERNAL_SERVER_ERROR,
371 "could not save the file",
372 ));
373 }
374 let tmp2 = tmp.clone();
375 let target2 = target.clone();
376 tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
377 .await
378 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))
379 .map_err(|e| e)?
380 .map_err(|e| {
381 let _ = std::fs::remove_file(&tmp);
382 tracing::warn!(error = %e, "rename failed during upload");
383 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
384 })?;
385 uploaded += 1;
386 }
387
388 if uploaded == 0 && skipped.is_empty() {
389 return Err(ApiError::new(
390 StatusCode::BAD_REQUEST,
391 "no files were uploaded",
392 ));
393 }
394 if !skipped.is_empty() {
395 return Err(
396 ApiError::new(
397 StatusCode::CONFLICT,
398 "some files already exist",
399 )
400 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
401 );
402 }
403 Ok(Json(serde_json::json!({ "ok": true, "uploaded": uploaded })))
404}
405
406fn parse_boundary(content_type: &str) -> Option<String> {
407 content_type
408 .split(';')
409 .map(|s| s.trim())
410 .find_map(|s| s.strip_prefix("boundary="))
411 .map(|b| b.trim_matches('"').to_string())
412 .filter(|b| !b.is_empty())
413}
414
415fn parse_overwrite(uri: &axum::http::Uri) -> bool {
416 uri.query()
417 .map(|q| {
418 q.split('&')
419 .any(|kv| kv == "overwrite=true" || kv == "overwrite=1")
420 })
421 .unwrap_or(false)
422}
423
424fn validate_rel_path(name: &str) -> Result<(), ApiError> {
425 for c in std::path::Path::new(name).components() {
426 match c {
427 Component::Normal(_) => {}
428 _ => {
429 return Err(ApiError::new(
430 StatusCode::BAD_REQUEST,
431 "invalid file path in upload",
432 ))
433 }
434 }
435 }
436 Ok(())
437}
438
439// ---------------------------------------------------------------------------
440// Helpers
441// ---------------------------------------------------------------------------
442
443fn find_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
444 roots
445 .iter()
446 .find(|r| r.id == root_id)
447 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
448}
449
450fn require_rw_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
451 let root = find_root(roots, root_id)?;
452 if root.mode != "rw" {
453 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
454 }
455 Ok(root)
456}
457