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 let mut res = Response::builder()
332 .status(StatusCode::OK)
333 .header(header::CONTENT_DISPOSITION, disp)
334 .header(header::CONTENT_LENGTH, size);
335 // A file the browser would parse as a document (HTML/SVG/XML) is served
336 // under the sandboxed policy, so it can render as a page without being
337 // able to act as the app. Derived from the same `mime` we declare.
338 if crate::api::is_scriptable_mime(&mime) {
339 res = res.header("content-security-policy", crate::api::FILE_CSP);
340 }
341 res.header(header::CONTENT_TYPE, mime)
342 .body(body)
343 .map_err(|e| {
344 ApiError::new(
345 StatusCode::INTERNAL_SERVER_ERROR,
346 format!("bad response: {e}"),
347 )
348 })
349}
350
351/// Stream `path` to the client in chunks (blocking reader → channel).
352fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
353 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
354 tokio::task::spawn_blocking(move || {
355 let mut f = match std::fs::File::open(&path) {
356 Ok(f) => f,
357 Err(e) => {
358 tracing::warn!(error = %e, path = %path.display(), "download open failed");
359 return;
360 }
361 };
362 let mut buf = vec![0u8; 256 * 1024];
363 loop {
364 match f.read(&mut buf) {
365 Ok(0) => break,
366 Ok(n) => {
367 // Client gone → stop producing.
368 if tx.blocking_send(buf[..n].to_vec()).is_err() {
369 break;
370 }
371 }
372 Err(e) => {
373 tracing::warn!(error = %e, path = %path.display(), "download read failed");
374 break;
375 }
376 }
377 }
378 });
379 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
380 axum::body::Body::from_stream(stream)
381}
382
383/// Stream an archive of `dir` (top-level entry `top`) to the client.
384fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
385 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
386 tokio::task::spawn_blocking(move || {
387 let mut sink = ChanWriter::new(tx);
388 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
389 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
390 }
391 // Dropping the sink flushes its buffer and closes the channel.
392 });
393 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
394 axum::body::Body::from_stream(stream)
395}
396
397/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
398/// bridge between the blocking archive builder and the async response body.
399struct ChanWriter {
400 tx: mpsc::Sender<Vec<u8>>,
401 buf: Vec<u8>,
402}
403
404impl ChanWriter {
405 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
406 Self {
407 tx,
408 buf: Vec::with_capacity(64 * 1024),
409 }
410 }
411}
412
413impl io::Write for ChanWriter {
414 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
415 self.buf.extend_from_slice(b);
416 if self.buf.len() >= 64 * 1024 {
417 io::Write::flush(self)?;
418 }
419 Ok(b.len())
420 }
421 fn flush(&mut self) -> io::Result<()> {
422 if !self.buf.is_empty() {
423 let chunk = std::mem::take(&mut self.buf);
424 self.tx
425 .blocking_send(chunk)
426 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
427 }
428 Ok(())
429 }
430}
431
432impl Drop for ChanWriter {
433 fn drop(&mut self) {
434 let _ = io::Write::flush(self);
435 }
436}
437
438// ---------------------------------------------------------------------------
439// POST dispatch: mkdir | rename/move/copy | upload
440// ---------------------------------------------------------------------------
441
442/// POST /api/files/{root_id} — top-level operations (upload into the root
443/// directory). The bare root never targets a specific item, so JSON ops with
444/// a missing folder name are rejected by the individual handlers.
445pub async fn dispatch_root(
446 State(state): State<Arc<AppState>>,
447 auth: AuthUser,
448 path: AxumPath<i64>,
449 headers: axum::http::HeaderMap,
450 req: axum::http::Request<axum::body::Body>,
451) -> Result<Response, ApiError> {
452 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
453}
454
455/// POST /api/files/{root_id}/{*path}
456pub async fn dispatch(
457 State(state): State<Arc<AppState>>,
458 auth: AuthUser,
459 path: AxumPath<(i64, String)>,
460 headers: axum::http::HeaderMap,
461 req: axum::http::Request<axum::body::Body>,
462) -> Result<Response, ApiError> {
463 let (root_id, req_rel) = path.0;
464 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
465}
466
467async fn dispatch_inner(
468 state: Arc<AppState>,
469 auth: AuthUser,
470 root_id: i64,
471 req_rel: String,
472 headers: axum::http::HeaderMap,
473 req: axum::http::Request<axum::body::Body>,
474) -> Result<Response, ApiError> {
475 let ct = headers
476 .get(header::CONTENT_TYPE)
477 .and_then(|v| v.to_str().ok())
478 .unwrap_or("");
479
480 if ct.starts_with("multipart/form-data") {
481 return upload(state, auth, root_id, req_rel, req).await;
482 }
483 if ct.starts_with("application/json") {
484 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
485 .await
486 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
487 let body: Mutation = axum::Json::from_bytes(&bytes)
488 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
489 .0;
490 return Ok(mutation(state, auth, root_id, req_rel, body)
491 .await?
492 .into_response());
493 }
494 Ok(mkdir(state, auth, root_id, req_rel).await?.into_response())
495}
496
497// ---------------------------------------------------------------------------
498// mkdir
499// ---------------------------------------------------------------------------
500
501async fn mkdir(
502 state: Arc<AppState>,
503 auth: AuthUser,
504 root_id: i64,
505 req_rel: String,
506) -> Result<Json<OkResp>, ApiError> {
507 let root = require_rw_root(&auth.roots, root_id)?;
508 if req_rel.trim().is_empty() {
509 return Err(ApiError::new(
510 StatusCode::BAD_REQUEST,
511 "a folder name is required",
512 ));
513 }
514 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
515 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
516 .await
517 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
518 Ok(Json(OkResp { ok: true }))
519}
520
521// ---------------------------------------------------------------------------
522// rename / move / copy
523// ---------------------------------------------------------------------------
524
525async fn mutation(
526 state: Arc<AppState>,
527 auth: AuthUser,
528 root_id: i64,
529 req_rel: String,
530 body: Mutation,
531) -> Result<Json<OkResp>, ApiError> {
532 match body.op.as_str() {
533 OP_RENAME => {
534 let new_name = body
535 .new_name
536 .as_deref()
537 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
538 .to_string();
539 let root = require_rw_root(&auth.roots, root_id)?;
540 let (server_root, root_rel, rel, overwrite) = (
541 state.root.clone(),
542 root.path.clone(),
543 req_rel,
544 body.overwrite,
545 );
546 tokio::task::spawn_blocking(move || {
547 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
548 })
549 .await
550 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
551 Ok(Json(OkResp { ok: true }))
552 }
553 OP_MOVE | OP_COPY => {
554 let dst_root_id = body
555 .dst_root_id
556 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
557 let dst = body.dst.clone().unwrap_or_default();
558 // Moving or copying out of a folder requires rw there; copying
559 // *from* a read-only root is fine.
560 let src_root = if body.op == "move" {
561 require_rw_root(&auth.roots, root_id)?
562 } else {
563 find_root(&auth.roots, root_id)?
564 };
565 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
566 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
567 state.root.clone(),
568 src_root.path.clone(),
569 dst_root.path.clone(),
570 dst,
571 req_rel,
572 body.overwrite,
573 );
574 let op_is_move = body.op == OP_MOVE;
575 tokio::task::spawn_blocking(move || {
576 if op_is_move {
577 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
578 } else {
579 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
580 }
581 })
582 .await
583 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
584 Ok(Json(OkResp { ok: true }))
585 }
586 _ => Err(ApiError::new(
587 StatusCode::BAD_REQUEST,
588 "unknown op (expected rename, move or copy)",
589 )),
590 }
591}
592
593// ---------------------------------------------------------------------------
594// DELETE
595// ---------------------------------------------------------------------------
596
597pub async fn delete(
598 State(state): State<Arc<AppState>>,
599 auth: AuthUser,
600 path: AxumPath<(i64, String)>,
601) -> Result<Json<serde_json::Value>, ApiError> {
602 let (root_id, req_rel) = path.0;
603 let root = require_rw_root(&auth.roots, root_id)?;
604 if req_rel.trim().is_empty() {
605 return Err(ApiError::new(
606 StatusCode::BAD_REQUEST,
607 "a path inside the folder is required",
608 ));
609 }
610 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
611 let is_dir =
612 tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
613 .await
614 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
615 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
616}
617
618// ---------------------------------------------------------------------------
619// Upload (multipart)
620// ---------------------------------------------------------------------------
621
622async fn upload(
623 state: Arc<AppState>,
624 auth: AuthUser,
625 root_id: i64,
626 req_rel: String,
627 req: axum::http::Request<axum::body::Body>,
628) -> Result<Response, ApiError> {
629 let root = require_rw_root(&auth.roots, root_id)?;
630 let base = {
631 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
632 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
633 .await
634 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
635 };
636 let boundary = req
637 .headers()
638 .get(header::CONTENT_TYPE)
639 .and_then(|v| v.to_str().ok())
640 .and_then(parse_boundary)
641 .ok_or_else(|| {
642 ApiError::new(
643 StatusCode::BAD_REQUEST,
644 "expected multipart/form-data with a boundary",
645 )
646 })?;
647 let overwrite = parse_overwrite(req.uri());
648
649 let stream = req.into_body().into_data_stream();
650 let mut multipart = Multipart::new(stream, boundary);
651 let mut uploaded: usize = 0;
652 let mut skipped: Vec<String> = Vec::new();
653
654 while let Some(mut field) = multipart
655 .next_field()
656 .await
657 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
658 {
659 let part_name = field
660 .name()
661 .filter(|n| !n.is_empty())
662 .or_else(|| field.file_name())
663 .map(str::to_string)
664 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
665
666 validate_rel_path(&part_name)?;
667
668 let target = base.join(&part_name);
669 let parent = target
670 .parent()
671 .filter(|p| !p.as_os_str().is_empty())
672 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
673 if !parent.is_dir() {
674 let p = parent.to_path_buf();
675 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
676 .await
677 .map_err(|_| {
678 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
679 })??;
680 }
681
682 if target.exists() && !overwrite {
683 // Drain this part and report it as a conflict at the end.
684 while let Some(_chunk) = field
685 .chunk()
686 .await
687 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
688 {
689 }
690 skipped.push(part_name);
691 continue;
692 }
693 if target.exists() {
694 if target.is_dir() {
695 return Err(ApiError::new(
696 StatusCode::CONFLICT,
697 "a folder with this name already exists",
698 ));
699 }
700 tokio::fs::remove_file(&target)
701 .await
702 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
703 }
704
705 // Stream to a temp file in the same directory, then rename into place.
706 let suffix = crate::auth::random_token();
707 let tmp = parent.join(format!(".upload-{suffix}"));
708 let mut tmp_file = tokio::fs::File::create(&tmp)
709 .await
710 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
711 let write_failed = loop {
712 match field
713 .chunk()
714 .await
715 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
716 {
717 Ok(Some(chunk)) => {
718 if let Err(e) = tmp_file.write_all(&chunk).await {
719 tracing::warn!(error = %e, "write failed during upload");
720 break true;
721 }
722 }
723 Ok(None) => break false,
724 Err(e) => return Err(e),
725 }
726 };
727 if write_failed {
728 let _ = tokio::fs::remove_file(&tmp).await;
729 return Err(ApiError::new(
730 StatusCode::INTERNAL_SERVER_ERROR,
731 "could not save the file",
732 ));
733 }
734 let tmp2 = tmp.clone();
735 let target2 = target.clone();
736 let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
737 .await
738 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
739 renamed.map_err(|e| {
740 let _ = std::fs::remove_file(&tmp);
741 tracing::warn!(error = %e, "rename failed during upload");
742 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
743 })?;
744 uploaded += 1;
745 }
746
747 if uploaded == 0 && skipped.is_empty() {
748 return Err(ApiError::new(
749 StatusCode::BAD_REQUEST,
750 "no files were uploaded",
751 ));
752 }
753 if !skipped.is_empty() {
754 return Err(
755 ApiError::new(StatusCode::CONFLICT, "some files already exist")
756 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
757 );
758 }
759 Ok(Json(UploadResp { ok: true, uploaded }).into_response())
760}
761
762fn parse_boundary(content_type: &str) -> Option<String> {
763 content_type
764 .split(';')
765 .map(|s| s.trim())
766 .find_map(|s| s.strip_prefix("boundary="))
767 .map(|b| b.trim_matches('"').to_string())
768 .filter(|b| !b.is_empty())
769}
770
771fn parse_overwrite(uri: &axum::http::Uri) -> bool {
772 uri.query()
773 .map(|q| {
774 let t = format!("{P_OVERWRITE}=true");
775 let o = format!("{P_OVERWRITE}=1");
776 q.split('&').any(|kv| kv == t || kv == o)
777 })
778 .unwrap_or(false)
779}
780fn validate_rel_path(name: &str) -> Result<(), ApiError> {
781 for c in std::path::Path::new(name).components() {
782 match c {
783 Component::Normal(_) => {}
784 _ => {
785 return Err(ApiError::new(
786 StatusCode::BAD_REQUEST,
787 "invalid file path in upload",
788 ));
789 }
790 }
791 }
792 Ok(())
793}
794
795// ---------------------------------------------------------------------------
796// Helpers
797// ---------------------------------------------------------------------------
798
799fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
800 roots
801 .iter()
802 .find(|r| r.id == root_id)
803 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
804}
805
806fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
807 let root = find_root(roots, root_id)?;
808 if root.mode != "rw" {
809 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
810 }
811 Ok(root)
812}
813