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::io::{self, Read};
13use std::path::Component;
14use std::sync::Arc;
15use std::time::UNIX_EPOCH;
16
17use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
18use axum::http::{header, StatusCode};
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use multer::Multipart;
22use futures_util::StreamExt;
23use tokio_stream::wrappers::ReceiverStream;
24use serde::Deserialize;
25use tokio::io::AsyncWriteExt;
26use tokio::sync::mpsc;
27
28use crate::api::common::AuthUser;
29use crate::archive::{self, ArchiveFormat};
30use crate::db::RootRow;
31use crate::error::{ApiError, AppState};
32use crate::fs::{self, FsError};
33
34/// Upper bound for the in-memory text endpoint (preview, later editor).
35const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
36
37// ---------------------------------------------------------------------------
38// Bodies / query params
39// ---------------------------------------------------------------------------
40
41#[derive(Deserialize)]
42pub struct MutationBody {
43 pub op: String, // "rename" | "move" | "copy"
44 #[serde(default)]
45 pub new_name: Option<String>,
46 #[serde(default)]
47 pub dst_root_id: Option<i64>,
48 #[serde(default)]
49 pub dst: Option<String>,
50 #[serde(default)]
51 pub overwrite: bool,
52}
53
54/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
55/// route lists the directory; `?action=download|preview|content` serve the
56/// item itself.
57#[derive(Deserialize, Default)]
58pub struct FileQuery {
59 #[serde(default)]
60 action: Option<String>,
61 #[serde(default)]
62 format: Option<String>,
63}
64
65// ---------------------------------------------------------------------------
66// Listing
67// ---------------------------------------------------------------------------
68
69/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
70/// itself via `?action=download|preview|content`.
71pub async fn file_get(
72 State(state): State<Arc<AppState>>,
73 auth: AuthUser,
74 path: AxumPath<(i64, String)>,
75 query: AxumQuery<FileQuery>,
76) -> Result<Response, ApiError> {
77 let (root_id, req_rel) = path.0;
78 match query.action.as_deref() {
79 Some("download") => download(state, auth, root_id, req_rel, query.format.as_deref()).await,
80 Some("preview") => preview(state, auth, root_id, req_rel).await,
81 Some("content") => content(state, auth, root_id, req_rel).await,
82 _ => {
83 let json = list_inner(state, auth, root_id, req_rel).await?;
84 Ok(json.into_response())
85 }
86 }
87}
88
89/// GET /api/files/{root_id} — list the root directory itself.
90pub async fn list_root(
91 State(state): State<Arc<AppState>>,
92 auth: AuthUser,
93 path: AxumPath<i64>,
94) -> Result<Json<serde_json::Value>, ApiError> {
95 list_inner(state, auth, path.0, String::new()).await
96}
97
98async fn list_inner(
99 state: Arc<AppState>,
100 auth: AuthUser,
101 root_id: i64,
102 req_rel: String,
103) -> Result<Json<serde_json::Value>, ApiError> {
104 let root = find_root(&auth.roots, root_id)?;
105 let server_root = state.root.clone();
106 let root_rel = root.path.clone();
107 let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
108 .await
109 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
110
111 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
112 .await
113 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
114
115 Ok(Json(serde_json::json!({ "entries": entries })))
116}
117
118// ---------------------------------------------------------------------------
119// download / preview / content (milestone 4)
120// ---------------------------------------------------------------------------
121
122/// Escape a file name for a `Content-Disposition` header value.
123fn disp_name(name: &str) -> String {
124 name.replace('\\', "\\\\")
125 .replace('"', "\\\"")
126 .replace('\n', "_")
127 .replace('\r', "_")
128}
129
130/// Resolve the requested item to an absolute path + metadata (blocking).
131async fn resolve_item(
132 state: &AppState,
133 root: &RootRow,
134 req_rel: &str,
135) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
136 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
137 let inner = tokio::task::spawn_blocking(move || {
138 let full = fs::resolve_path(&server_root, &root_rel, &rel)?;
139 let name = full
140 .file_name()
141 .map(|n| n.to_string_lossy().into_owned())
142 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
143 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
144 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
145 })
146 .await
147 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
148 Ok(inner?)
149}
150
151/// `GET ...?action=download` — a single file as-is, a folder as an archive
152/// (format chosen by the client).
153async fn download(
154 state: Arc<AppState>,
155 auth: AuthUser,
156 root_id: i64,
157 req_rel: String,
158 format: Option<&str>,
159) -> Result<Response, ApiError> {
160 let root = find_root(&auth.roots, root_id)?;
161 let (full, name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
162
163 if !is_dir {
164 return file_response(&full, &name, size, false).await;
165 }
166
167 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
168 ApiError::new(
169 StatusCode::BAD_REQUEST,
170 "format must be one of: zip, tar, tar.gz, tar.zst",
171 )
172 })?;
173 let disp = format!(
174 "attachment; filename=\"{}.{}\"",
175 disp_name(&name),
176 fmt.extension()
177 );
178 let body = stream_archive(fmt, full, name);
179 Response::builder()
180 .status(StatusCode::OK)
181 .header(header::CONTENT_TYPE, fmt.mime())
182 .header(header::CONTENT_DISPOSITION, disp)
183 .body(body)
184 .map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("bad response: {e}")))
185}
186
187/// `GET ...?action=preview` — a single file, inline (for native media).
188async fn preview(
189 state: Arc<AppState>,
190 auth: AuthUser,
191 root_id: i64,
192 req_rel: String,
193) -> Result<Response, ApiError> {
194 let root = find_root(&auth.roots, root_id)?;
195 let (full, name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
196 if is_dir {
197 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
198 }
199 file_response(&full, &name, size, true).await
200}
201
202/// `GET ...?action=content` — raw file bytes for the text preview/editor.
203/// Capped at `MAX_TEXT_BYTES`.
204async fn content(
205 state: Arc<AppState>,
206 auth: AuthUser,
207 root_id: i64,
208 req_rel: String,
209) -> Result<Response, ApiError> {
210 let root = find_root(&auth.roots, root_id)?;
211 let (full, _name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
212 if is_dir {
213 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
214 }
215 if size > MAX_TEXT_BYTES {
216 return Err(ApiError::new(
217 StatusCode::PAYLOAD_TOO_LARGE,
218 "file too large to preview",
219 ));
220 }
221 let bytes = tokio::fs::read(&full)
222 .await
223 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
224 let meta = tokio::fs::metadata(&full)
225 .await
226 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
227 let mtime = meta
228 .modified()
229 .ok()
230 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
231 .map(|d| d.as_secs())
232 .unwrap_or(0);
233 Ok((
234 [
235 (
236 header::CONTENT_TYPE,
237 "text/plain; charset=utf-8".to_string(),
238 ),
239 (
240 axum::http::HeaderName::from_static("x-file-mtime"),
241 mtime.to_string(),
242 ),
243 ],
244 bytes,
245 )
246 .into_response())
247}
248
249/// `PUT ...?action=content` — save a file's text contents (the editor).
250///
251/// Requires a read-write root. The body is the new contents. If the
252/// `X-Expected-Mtime` header is present, the file's current mtime must match
253/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
254/// Returns the file's new mtime so the client can anchor the next check.
255pub async fn file_put(
256 State(state): State<Arc<AppState>>,
257 auth: AuthUser,
258 path: AxumPath<(i64, String)>,
259 query: AxumQuery<FileQuery>,
260 headers: axum::http::HeaderMap,
261 body: axum::body::Bytes,
262) -> Result<Json<serde_json::Value>, ApiError> {
263 let (root_id, req_rel) = path.0;
264 if query.action.as_deref() != Some("content") {
265 return Err(ApiError::new(
266 StatusCode::BAD_REQUEST,
267 "expected action=content",
268 ));
269 }
270 let root = require_rw_root(&auth.roots, root_id)?;
271 if body.len() as u64 > MAX_TEXT_BYTES {
272 return Err(ApiError::new(
273 StatusCode::PAYLOAD_TOO_LARGE,
274 "file too large to save",
275 ));
276 }
277 let expected: Option<i64> = headers
278 .get("x-expected-mtime")
279 .and_then(|v| v.to_str().ok())
280 .and_then(|s| s.parse().ok());
281 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
282 let content = body.to_vec();
283 let mtime = tokio::task::spawn_blocking(move || {
284 fs::save_file(&server_root, &root_rel, &rel, &content, expected)
285 })
286 .await
287 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
288 Ok(Json(serde_json::json!({ "ok": true, "mtime": mtime })))
289}
290
291/// Stream a single file to the client with the right disposition.
292async fn file_response(
293 full: &std::path::Path,
294 name: &str,
295 size: u64,
296 inline: bool,
297) -> Result<Response, ApiError> {
298 let mime = mime_guess::from_path(full).first_or_octet_stream().to_string();
299 let disp = if inline {
300 format!("inline; filename=\"{}\"", disp_name(name))
301 } else {
302 format!("attachment; filename=\"{}\"", disp_name(name))
303 };
304 let body = stream_file(full.to_path_buf());
305 Response::builder()
306 .status(StatusCode::OK)
307 .header(header::CONTENT_TYPE, mime)
308 .header(header::CONTENT_DISPOSITION, disp)
309 .header(header::CONTENT_LENGTH, size)
310 .body(body)
311 .map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("bad response: {e}")))
312}
313
314/// Stream `path` to the client in chunks (blocking reader → channel).
315fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
316 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
317 tokio::task::spawn_blocking(move || {
318 let mut f = match std::fs::File::open(&path) {
319 Ok(f) => f,
320 Err(e) => {
321 tracing::warn!(error = %e, path = %path.display(), "download open failed");
322 return;
323 }
324 };
325 let mut buf = vec![0u8; 256 * 1024];
326 loop {
327 match f.read(&mut buf) {
328 Ok(0) => break,
329 Ok(n) => {
330 // Client gone → stop producing.
331 if tx.blocking_send(buf[..n].to_vec()).is_err() {
332 break;
333 }
334 }
335 Err(e) => {
336 tracing::warn!(error = %e, path = %path.display(), "download read failed");
337 break;
338 }
339 }
340 }
341 });
342 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
343 axum::body::Body::from_stream(stream)
344}
345
346/// Stream an archive of `dir` (top-level entry `top`) to the client.
347fn stream_archive(
348 fmt: ArchiveFormat,
349 dir: std::path::PathBuf,
350 top: String,
351) -> axum::body::Body {
352 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
353 tokio::task::spawn_blocking(move || {
354 let mut sink = ChanWriter::new(tx);
355 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
356 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
357 }
358 // Dropping the sink flushes its buffer and closes the channel.
359 });
360 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
361 axum::body::Body::from_stream(stream)
362}
363
364/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
365/// bridge between the blocking archive builder and the async response body.
366struct ChanWriter {
367 tx: mpsc::Sender<Vec<u8>>,
368 buf: Vec<u8>,
369}
370
371impl ChanWriter {
372 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
373 Self {
374 tx,
375 buf: Vec::with_capacity(64 * 1024),
376 }
377 }
378}
379
380impl io::Write for ChanWriter {
381 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
382 self.buf.extend_from_slice(b);
383 if self.buf.len() >= 64 * 1024 {
384 io::Write::flush(self)?;
385 }
386 Ok(b.len())
387 }
388 fn flush(&mut self) -> io::Result<()> {
389 if !self.buf.is_empty() {
390 let chunk = std::mem::take(&mut self.buf);
391 self.tx
392 .blocking_send(chunk)
393 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
394 }
395 Ok(())
396 }
397}
398
399impl Drop for ChanWriter {
400 fn drop(&mut self) {
401 let _ = io::Write::flush(self);
402 }
403}
404
405// ---------------------------------------------------------------------------
406// POST dispatch: mkdir | rename/move/copy | upload
407// ---------------------------------------------------------------------------
408
409/// POST /api/files/{root_id} — top-level operations (upload into the root
410/// directory). The bare root never targets a specific item, so JSON ops with
411/// a missing folder name are rejected by the individual handlers.
412pub async fn dispatch_root(
413 State(state): State<Arc<AppState>>,
414 auth: AuthUser,
415 path: AxumPath<i64>,
416 headers: axum::http::HeaderMap,
417 req: axum::http::Request<axum::body::Body>,
418) -> Result<Json<serde_json::Value>, ApiError> {
419 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
420}
421
422/// POST /api/files/{root_id}/{*path}
423pub async fn dispatch(
424 State(state): State<Arc<AppState>>,
425 auth: AuthUser,
426 path: AxumPath<(i64, String)>,
427 headers: axum::http::HeaderMap,
428 req: axum::http::Request<axum::body::Body>,
429) -> Result<Json<serde_json::Value>, ApiError> {
430 let (root_id, req_rel) = path.0;
431 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
432}
433
434async fn dispatch_inner(
435 state: Arc<AppState>,
436 auth: AuthUser,
437 root_id: i64,
438 req_rel: String,
439 headers: axum::http::HeaderMap,
440 req: axum::http::Request<axum::body::Body>,
441) -> Result<Json<serde_json::Value>, ApiError> {
442 let ct = headers
443 .get(header::CONTENT_TYPE)
444 .and_then(|v| v.to_str().ok())
445 .unwrap_or("");
446
447 if ct.starts_with("multipart/form-data") {
448 return upload(state, auth, root_id, req_rel, req).await;
449 }
450 if ct.starts_with("application/json") {
451 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
452 .await
453 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
454 let body: MutationBody = axum::Json::from_bytes(&bytes)
455 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
456 .0;
457 return mutation(state, auth, root_id, req_rel, body).await;
458 }
459 mkdir(state, auth, root_id, req_rel).await
460}
461
462// ---------------------------------------------------------------------------
463// mkdir
464// ---------------------------------------------------------------------------
465
466async fn mkdir(
467 state: Arc<AppState>,
468 auth: AuthUser,
469 root_id: i64,
470 req_rel: String,
471) -> Result<Json<serde_json::Value>, ApiError> {
472 let root = require_rw_root(&auth.roots, root_id)?;
473 if req_rel.trim().is_empty() {
474 return Err(ApiError::new(
475 StatusCode::BAD_REQUEST,
476 "a folder name is required",
477 ));
478 }
479 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
480 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
481 .await
482 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
483 Ok(Json(serde_json::json!({ "ok": true })))
484}
485
486// ---------------------------------------------------------------------------
487// rename / move / copy
488// ---------------------------------------------------------------------------
489
490async fn mutation(
491 state: Arc<AppState>,
492 auth: AuthUser,
493 root_id: i64,
494 req_rel: String,
495 body: MutationBody,
496) -> Result<Json<serde_json::Value>, ApiError> {
497 match body.op.as_str() {
498 "rename" => {
499 let new_name = body
500 .new_name
501 .as_deref()
502 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
503 .to_string();
504 let root = require_rw_root(&auth.roots, root_id)?;
505 let (server_root, root_rel, rel, overwrite) =
506 (state.root.clone(), root.path.clone(), req_rel, body.overwrite);
507 tokio::task::spawn_blocking(move || fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite))
508 .await
509 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
510 Ok(Json(serde_json::json!({ "ok": true })))
511 }
512 "move" | "copy" => {
513 let dst_root_id = body
514 .dst_root_id
515 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
516 let dst = body
517 .dst
518 .clone()
519 .unwrap_or_default();
520 // Moving or copying out of a folder requires rw there; copying
521 // *from* a read-only root is fine.
522 let src_root = if body.op == "move" {
523 require_rw_root(&auth.roots, root_id)?
524 } else {
525 find_root(&auth.roots, root_id)?
526 };
527 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
528 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
529 state.root.clone(),
530 src_root.path.clone(),
531 dst_root.path.clone(),
532 dst,
533 req_rel,
534 body.overwrite,
535 );
536 let op_is_move = body.op == "move";
537 tokio::task::spawn_blocking(move || {
538 if op_is_move {
539 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
540 } else {
541 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
542 }
543 })
544 .await
545 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
546 Ok(Json(serde_json::json!({ "ok": true })))
547 }
548 _ => Err(ApiError::new(
549 StatusCode::BAD_REQUEST,
550 "unknown op (expected rename, move or copy)",
551 )),
552 }
553}
554
555// ---------------------------------------------------------------------------
556// DELETE
557// ---------------------------------------------------------------------------
558
559pub async fn delete(
560 State(state): State<Arc<AppState>>,
561 auth: AuthUser,
562 path: AxumPath<(i64, String)>,
563) -> Result<Json<serde_json::Value>, ApiError> {
564 let (root_id, req_rel) = path.0;
565 let root = require_rw_root(&auth.roots, root_id)?;
566 if req_rel.trim().is_empty() {
567 return Err(ApiError::new(
568 StatusCode::BAD_REQUEST,
569 "a path inside the folder is required",
570 ));
571 }
572 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
573 let is_dir = tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
574 .await
575 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
576 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
577}
578
579// ---------------------------------------------------------------------------
580// Upload (multipart)
581// ---------------------------------------------------------------------------
582
583async fn upload(
584 state: Arc<AppState>,
585 auth: AuthUser,
586 root_id: i64,
587 req_rel: String,
588 req: axum::http::Request<axum::body::Body>,
589) -> Result<Json<serde_json::Value>, ApiError> {
590 let root = require_rw_root(&auth.roots, root_id)?;
591 let base = {
592 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
593 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
594 .await
595 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
596 };
597 let boundary = req
598 .headers()
599 .get(header::CONTENT_TYPE)
600 .and_then(|v| v.to_str().ok())
601 .and_then(parse_boundary)
602 .ok_or_else(|| {
603 ApiError::new(
604 StatusCode::BAD_REQUEST,
605 "expected multipart/form-data with a boundary",
606 )
607 })?;
608 let overwrite = parse_overwrite(req.uri());
609
610 let stream = req.into_body().into_data_stream();
611 let mut multipart = Multipart::new(stream, boundary);
612 let mut uploaded: usize = 0;
613 let mut skipped: Vec<String> = Vec::new();
614
615 while let Some(mut field) = multipart
616 .next_field()
617 .await
618 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
619 {
620 let part_name = field
621 .name()
622 .filter(|n| !n.is_empty())
623 .or_else(|| field.file_name())
624 .map(str::to_string)
625 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
626
627 validate_rel_path(&part_name)?;
628
629 let target = base.join(&part_name);
630 let parent = target
631 .parent()
632 .filter(|p| !p.as_os_str().is_empty())
633 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
634 if !parent.is_dir() {
635 let p = parent.to_path_buf();
636 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
637 .await
638 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
639 }
640
641 if target.exists() && !overwrite {
642 // Drain this part and report it as a conflict at the end.
643 while let Some(_chunk) = field
644 .chunk()
645 .await
646 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
647 {}
648 skipped.push(part_name);
649 continue;
650 }
651 if target.exists() {
652 if target.is_dir() {
653 return Err(ApiError::new(
654 StatusCode::CONFLICT,
655 "a folder with this name already exists",
656 ));
657 }
658 tokio::fs::remove_file(&target)
659 .await
660 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
661 }
662
663 // Stream to a temp file in the same directory, then rename into place.
664 let suffix = crate::auth::random_token();
665 let tmp = parent.join(format!(".upload-{suffix}"));
666 let mut tmp_file = tokio::fs::File::create(&tmp)
667 .await
668 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
669 let write_failed = loop {
670 match field
671 .chunk()
672 .await
673 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
674 {
675 Ok(Some(chunk)) => {
676 if let Err(e) = tmp_file.write_all(&chunk).await {
677 tracing::warn!(error = %e, "write failed during upload");
678 break true;
679 }
680 }
681 Ok(None) => break false,
682 Err(e) => return Err(e),
683 }
684 };
685 if write_failed {
686 let _ = tokio::fs::remove_file(&tmp).await;
687 return Err(ApiError::new(
688 StatusCode::INTERNAL_SERVER_ERROR,
689 "could not save the file",
690 ));
691 }
692 let tmp2 = tmp.clone();
693 let target2 = target.clone();
694 tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
695 .await
696 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))
697 .map_err(|e| e)?
698 .map_err(|e| {
699 let _ = std::fs::remove_file(&tmp);
700 tracing::warn!(error = %e, "rename failed during upload");
701 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
702 })?;
703 uploaded += 1;
704 }
705
706 if uploaded == 0 && skipped.is_empty() {
707 return Err(ApiError::new(
708 StatusCode::BAD_REQUEST,
709 "no files were uploaded",
710 ));
711 }
712 if !skipped.is_empty() {
713 return Err(
714 ApiError::new(
715 StatusCode::CONFLICT,
716 "some files already exist",
717 )
718 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
719 );
720 }
721 Ok(Json(serde_json::json!({ "ok": true, "uploaded": uploaded })))
722}
723
724fn parse_boundary(content_type: &str) -> Option<String> {
725 content_type
726 .split(';')
727 .map(|s| s.trim())
728 .find_map(|s| s.strip_prefix("boundary="))
729 .map(|b| b.trim_matches('"').to_string())
730 .filter(|b| !b.is_empty())
731}
732
733fn parse_overwrite(uri: &axum::http::Uri) -> bool {
734 uri.query()
735 .map(|q| {
736 q.split('&')
737 .any(|kv| kv == "overwrite=true" || kv == "overwrite=1")
738 })
739 .unwrap_or(false)
740}
741
742fn validate_rel_path(name: &str) -> Result<(), ApiError> {
743 for c in std::path::Path::new(name).components() {
744 match c {
745 Component::Normal(_) => {}
746 _ => {
747 return Err(ApiError::new(
748 StatusCode::BAD_REQUEST,
749 "invalid file path in upload",
750 ))
751 }
752 }
753 }
754 Ok(())
755}
756
757// ---------------------------------------------------------------------------
758// Helpers
759// ---------------------------------------------------------------------------
760
761fn find_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
762 roots
763 .iter()
764 .find(|r| r.id == root_id)
765 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
766}
767
768fn require_rw_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
769 let root = find_root(roots, root_id)?;
770 if root.mode != "rw" {
771 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
772 }
773 Ok(root)
774}
775