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