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