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