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;
15
16use axum::Json;
17use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
18use axum::http::{StatusCode, header};
19use axum::response::{IntoResponse, Response};
20use futures_util::StreamExt;
21use multer::Multipart;
22use serde::Deserialize;
23use tokio::io::AsyncWriteExt;
24use tokio::sync::mpsc;
25use tokio_stream::wrappers::ReceiverStream;
26
27use crate::api::common::AuthUser;
28use crate::archive::{self, ArchiveFormat};
29use crate::db::{RootRow, ShareRow};
30use crate::error::{ApiError, AppState};
31use crate::fs::{self, FsError};
32use api_types::{FilesResp, Mutation, OkResp, Op, P_ACTION, P_OVERWRITE, SaveResp, UploadResp};
33
34/// Upper bound for the in-memory text endpoint (preview, later editor).
35const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
36
37// ---------------------------------------------------------------------------
38// Query params
39// ---------------------------------------------------------------------------
40
41/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
42/// route lists the directory; `?action=download|preview|content` serve the
43/// item itself.
44#[derive(Deserialize, Default)]
45pub struct FileQuery {
46 #[serde(default)]
47 action: Option<String>,
48 #[serde(default)]
49 format: Option<String>,
50}
51
52// ---------------------------------------------------------------------------
53// Listing
54// ---------------------------------------------------------------------------
55
56/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
57/// itself via `?action=download|preview|content`.
58pub async fn file_get(
59 State(state): State<Arc<AppState>>,
60 auth: AuthUser,
61 path: AxumPath<(i64, String)>,
62 query: AxumQuery<FileQuery>,
63) -> Result<Response, ApiError> {
64 let (root_id, req_rel) = path.0;
65 match query.action.as_deref() {
66 Some(a) if a == api_types::ACTION_DOWNLOAD => {
67 download(state, auth, root_id, req_rel, query.format.as_deref()).await
68 }
69 Some(a) if a == api_types::ACTION_PREVIEW => preview(state, auth, root_id, req_rel).await,
70 Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
71 _ => {
72 let json = list_inner(state, auth, root_id, req_rel).await?;
73 Ok(json.into_response())
74 }
75 }
76}
77
78/// GET /api/files/{root_id} — list the root directory itself, or serve the
79/// root item via `?action=download|preview|content` (the root of a *file*
80/// share is the file itself).
81pub async fn list_root(
82 State(state): State<Arc<AppState>>,
83 auth: AuthUser,
84 path: AxumPath<i64>,
85 query: AxumQuery<FileQuery>,
86) -> Result<Response, ApiError> {
87 let root_id = path.0;
88 match query.action.as_deref() {
89 Some(a) if a == api_types::ACTION_DOWNLOAD => {
90 download(state, auth, root_id, String::new(), query.format.as_deref()).await
91 }
92 Some(a) if a == api_types::ACTION_PREVIEW => {
93 preview(state, auth, root_id, String::new()).await
94 }
95 Some(a) if a == api_types::ACTION_CONTENT => {
96 content(state, auth, root_id, String::new()).await
97 }
98 _ => {
99 let json = list_inner(state, auth, root_id, String::new()).await?;
100 Ok(json.into_response())
101 }
102 }
103}
104
105async fn list_inner(
106 state: Arc<AppState>,
107 auth: AuthUser,
108 root_id: i64,
109 req_rel: String,
110) -> Result<Json<FilesResp>, ApiError> {
111 let root = find_root(&auth.roots, root_id)?;
112 let server_root = state.root.clone();
113 let root_rel = root.path.clone();
114 let full =
115 tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
116 .await
117 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
118
119 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
120 .await
121 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
122
123 Ok(Json(FilesResp { entries }))
124}
125
126// ---------------------------------------------------------------------------
127// download / preview / content (milestone 4)
128// ---------------------------------------------------------------------------
129
130/// Escape a file name for a `Content-Disposition` header value.
131fn disp_name(name: &str) -> String {
132 name.replace('\\', "\\\\")
133 .replace('"', "\\\"")
134 .replace(['\n', '\r'], "_")
135}
136
137/// Resolve the requested item to an absolute path + metadata (blocking).
138async fn resolve_item(
139 state: &AppState,
140 root: &RootRow,
141 req_rel: &str,
142 share: Option<&ShareRow>,
143) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
144 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
145 // A file share's synthetic root *is* the file, so resolve it directly.
146 let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
147 let inner = tokio::task::spawn_blocking(move || {
148 let full = match share_target {
149 Some(target) => fs::resolve_file(&server_root, &target)?,
150 None => fs::resolve_path(&server_root, &root_rel, &rel)?,
151 };
152 let name = full
153 .file_name()
154 .map(|n| n.to_string_lossy().into_owned())
155 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
156 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
157 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
158 })
159 .await
160 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
161 Ok(inner?)
162}
163
164/// `GET ...?action=download` — a single file as-is, a folder as an archive
165/// (format chosen by the client).
166async fn download(
167 state: Arc<AppState>,
168 auth: AuthUser,
169 root_id: i64,
170 req_rel: String,
171 format: Option<&str>,
172) -> Result<Response, ApiError> {
173 let root = find_root(&auth.roots, root_id)?;
174 let (full, name, is_dir, size) =
175 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
176
177 if !is_dir {
178 return file_response(&full, &name, size, false).await;
179 }
180
181 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
182 ApiError::new(
183 StatusCode::BAD_REQUEST,
184 "format must be one of: zip, tar, tar.gz, tar.zst",
185 )
186 })?;
187 let disp = format!(
188 "attachment; filename=\"{}.{}\"",
189 disp_name(&name),
190 fmt.extension()
191 );
192 let body = stream_archive(fmt, full, name);
193 Response::builder()
194 .status(StatusCode::OK)
195 .header(header::CONTENT_TYPE, fmt.mime())
196 .header(header::CONTENT_DISPOSITION, disp)
197 .body(body)
198 .map_err(|e| {
199 ApiError::new(
200 StatusCode::INTERNAL_SERVER_ERROR,
201 format!("bad response: {e}"),
202 )
203 })
204}
205
206/// `GET ...?action=preview` — a single file, inline (for native media).
207async fn preview(
208 state: Arc<AppState>,
209 auth: AuthUser,
210 root_id: i64,
211 req_rel: String,
212) -> Result<Response, ApiError> {
213 let root = find_root(&auth.roots, root_id)?;
214 let (full, name, is_dir, size) =
215 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
216 if is_dir {
217 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
218 }
219 file_response(&full, &name, size, true).await
220}
221
222/// `GET ...?action=content` — raw file bytes for the text preview/editor.
223/// Capped at `MAX_TEXT_BYTES`.
224async fn content(
225 state: Arc<AppState>,
226 auth: AuthUser,
227 root_id: i64,
228 req_rel: String,
229) -> Result<Response, ApiError> {
230 let root = find_root(&auth.roots, root_id)?;
231 let (full, _name, is_dir, size) =
232 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
233 if is_dir {
234 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
235 }
236 if size > MAX_TEXT_BYTES {
237 return Err(ApiError::new(
238 StatusCode::PAYLOAD_TOO_LARGE,
239 "file too large to preview",
240 ));
241 }
242 let bytes = tokio::fs::read(&full)
243 .await
244 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
245 let meta = tokio::fs::metadata(&full)
246 .await
247 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
248 let mtime = fs::mtime_secs(&meta).unwrap_or(0);
249 Ok((
250 [
251 (
252 header::CONTENT_TYPE,
253 "text/plain; charset=utf-8".to_string(),
254 ),
255 (
256 axum::http::HeaderName::from_static("x-file-mtime"),
257 mtime.to_string(),
258 ),
259 ],
260 bytes,
261 )
262 .into_response())
263}
264
265/// `PUT ...?action=content` — save a file's text contents (the editor).
266///
267/// Requires a read-write root. The body is the new contents. If the
268/// `X-Expected-Mtime` header is present, the file's current mtime must match
269/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
270/// Returns the file's new mtime so the client can anchor the next check.
271pub async fn file_put(
272 State(state): State<Arc<AppState>>,
273 auth: AuthUser,
274 path: AxumPath<(i64, String)>,
275 query: AxumQuery<FileQuery>,
276 headers: axum::http::HeaderMap,
277 body: axum::body::Bytes,
278) -> Result<Json<SaveResp>, ApiError> {
279 let (root_id, req_rel) = path.0;
280 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
281 return Err(ApiError::new(
282 StatusCode::BAD_REQUEST,
283 "expected action=content",
284 ));
285 }
286 let root = require_rw_root(&auth.roots, root_id)?;
287 if body.len() as u64 > MAX_TEXT_BYTES {
288 return Err(ApiError::new(
289 StatusCode::PAYLOAD_TOO_LARGE,
290 "file too large to save",
291 ));
292 }
293 let expected: Option<i64> = headers
294 .get("x-expected-mtime")
295 .and_then(|v| v.to_str().ok())
296 .and_then(|s| s.parse().ok());
297 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
298 let content = body.to_vec();
299 let mtime = tokio::task::spawn_blocking(move || {
300 fs::save_file(&server_root, &root_rel, &rel, &content, expected)
301 })
302 .await
303 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
304 Ok(Json(SaveResp { ok: true, mtime }))
305}
306
307/// Stream a single file to the client with the right disposition.
308async fn file_response(
309 full: &std::path::Path,
310 name: &str,
311 size: u64,
312 inline: bool,
313) -> Result<Response, ApiError> {
314 let mime = mime_guess::from_path(full)
315 .first_or_octet_stream()
316 .to_string();
317 let disp = if inline {
318 format!("inline; filename=\"{}\"", disp_name(name))
319 } else {
320 format!("attachment; filename=\"{}\"", disp_name(name))
321 };
322 let body = stream_file(full.to_path_buf());
323 let mut res = Response::builder()
324 .status(StatusCode::OK)
325 .header(header::CONTENT_DISPOSITION, disp)
326 .header(header::CONTENT_LENGTH, size);
327 // A file the browser would parse as a document (HTML/SVG/XML) is served
328 // under the sandboxed policy, so it can render as a page without being
329 // able to act as the app. Derived from the same `mime` we declare.
330 if crate::api::is_scriptable_mime(&mime) {
331 res = res.header("content-security-policy", crate::api::FILE_CSP);
332 }
333 res.header(header::CONTENT_TYPE, mime)
334 .body(body)
335 .map_err(|e| {
336 ApiError::new(
337 StatusCode::INTERNAL_SERVER_ERROR,
338 format!("bad response: {e}"),
339 )
340 })
341}
342
343/// Stream `path` to the client in chunks (blocking reader → channel).
344fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
345 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
346 tokio::task::spawn_blocking(move || {
347 let mut f = match std::fs::File::open(&path) {
348 Ok(f) => f,
349 Err(e) => {
350 tracing::warn!(error = %e, path = %path.display(), "download open failed");
351 return;
352 }
353 };
354 let mut buf = vec![0u8; 256 * 1024];
355 loop {
356 match f.read(&mut buf) {
357 Ok(0) => break,
358 Ok(n) => {
359 // Client gone → stop producing.
360 if tx.blocking_send(buf[..n].to_vec()).is_err() {
361 break;
362 }
363 }
364 Err(e) => {
365 tracing::warn!(error = %e, path = %path.display(), "download read failed");
366 break;
367 }
368 }
369 }
370 });
371 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
372 axum::body::Body::from_stream(stream)
373}
374
375/// Stream an archive of `dir` (top-level entry `top`) to the client.
376fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
377 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
378 tokio::task::spawn_blocking(move || {
379 let mut sink = ChanWriter::new(tx);
380 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
381 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
382 }
383 // Dropping the sink flushes its buffer and closes the channel.
384 });
385 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
386 axum::body::Body::from_stream(stream)
387}
388
389/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
390/// bridge between the blocking archive builder and the async response body.
391struct ChanWriter {
392 tx: mpsc::Sender<Vec<u8>>,
393 buf: Vec<u8>,
394}
395
396impl ChanWriter {
397 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
398 Self {
399 tx,
400 buf: Vec::with_capacity(64 * 1024),
401 }
402 }
403}
404
405impl io::Write for ChanWriter {
406 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
407 self.buf.extend_from_slice(b);
408 if self.buf.len() >= 64 * 1024 {
409 io::Write::flush(self)?;
410 }
411 Ok(b.len())
412 }
413 fn flush(&mut self) -> io::Result<()> {
414 if !self.buf.is_empty() {
415 let chunk = std::mem::take(&mut self.buf);
416 self.tx
417 .blocking_send(chunk)
418 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
419 }
420 Ok(())
421 }
422}
423
424impl Drop for ChanWriter {
425 fn drop(&mut self) {
426 let _ = io::Write::flush(self);
427 }
428}
429
430// ---------------------------------------------------------------------------
431// POST dispatch: mkdir | rename/move/copy | upload
432// ---------------------------------------------------------------------------
433
434/// POST /api/files/{root_id} — top-level operations (upload into the root
435/// directory). The bare root never targets a specific item, so JSON ops with
436/// a missing folder name are rejected by the individual handlers.
437pub async fn dispatch_root(
438 State(state): State<Arc<AppState>>,
439 auth: AuthUser,
440 path: AxumPath<i64>,
441 headers: axum::http::HeaderMap,
442 req: axum::http::Request<axum::body::Body>,
443) -> Result<Response, ApiError> {
444 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
445}
446
447/// POST /api/files/{root_id}/{*path}
448pub async fn dispatch(
449 State(state): State<Arc<AppState>>,
450 auth: AuthUser,
451 path: AxumPath<(i64, String)>,
452 headers: axum::http::HeaderMap,
453 req: axum::http::Request<axum::body::Body>,
454) -> Result<Response, ApiError> {
455 let (root_id, req_rel) = path.0;
456 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
457}
458
459/// Route one POST to upload, mutation or mkdir.
460///
461/// Upload and mutation are recognized by their content type. mkdir carries no
462/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
463/// an unrecognized content type used to fall through to mkdir, which turned a
464/// typo in a header into a silently created folder.
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<Response, 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: Mutation = axum::Json::from_bytes(&bytes)
486 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
487 .0;
488 return Ok(mutation(state, auth, root_id, req_rel, body)
489 .await?
490 .into_response());
491 }
492 if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
493 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
494 }
495 Err(ApiError::new(
496 StatusCode::UNSUPPORTED_MEDIA_TYPE,
497 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
498 ))
499}
500
501// ---------------------------------------------------------------------------
502// mkdir
503// ---------------------------------------------------------------------------
504
505async fn mkdir(
506 state: Arc<AppState>,
507 auth: AuthUser,
508 root_id: i64,
509 req_rel: String,
510) -> Result<Json<OkResp>, ApiError> {
511 let root = require_rw_root(&auth.roots, root_id)?;
512 if req_rel.trim().is_empty() {
513 return Err(ApiError::new(
514 StatusCode::BAD_REQUEST,
515 "a folder name is required",
516 ));
517 }
518 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
519 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
520 .await
521 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
522 Ok(Json(OkResp { ok: true }))
523}
524
525// ---------------------------------------------------------------------------
526// rename / move / copy
527// ---------------------------------------------------------------------------
528
529async fn mutation(
530 state: Arc<AppState>,
531 auth: AuthUser,
532 root_id: i64,
533 req_rel: String,
534 body: Mutation,
535) -> Result<Json<OkResp>, ApiError> {
536 match body.op {
537 Op::Rename => {
538 let new_name = body
539 .new_name
540 .as_deref()
541 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
542 .to_string();
543 let root = require_rw_root(&auth.roots, root_id)?;
544 let (server_root, root_rel, rel, overwrite) = (
545 state.root.clone(),
546 root.path.clone(),
547 req_rel,
548 body.overwrite,
549 );
550 tokio::task::spawn_blocking(move || {
551 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
552 })
553 .await
554 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
555 Ok(Json(OkResp { ok: true }))
556 }
557 Op::Move | Op::Copy => {
558 let dst_root_id = body
559 .dst_root_id
560 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
561 let dst = body.dst.clone().unwrap_or_default();
562 // Moving or copying out of a folder requires rw there; copying
563 // *from* a read-only root is fine.
564 let op_is_move = body.op == Op::Move;
565 let src_root = if op_is_move {
566 require_rw_root(&auth.roots, root_id)?
567 } else {
568 find_root(&auth.roots, root_id)?
569 };
570 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
571 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
572 state.root.clone(),
573 src_root.path.clone(),
574 dst_root.path.clone(),
575 dst,
576 req_rel,
577 body.overwrite,
578 );
579 tokio::task::spawn_blocking(move || {
580 if op_is_move {
581 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
582 } else {
583 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
584 }
585 })
586 .await
587 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
588 Ok(Json(OkResp { ok: true }))
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
771/// Read one query parameter from the request URI.
772fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
773 uri.query()?.split('&').find_map(|kv| {
774 let (k, v) = kv.split_once('=')?;
775 (k == key).then(|| v.to_string())
776 })
777}
778
779fn action_param(uri: &axum::http::Uri) -> Option<String> {
780 query_param(uri, P_ACTION)
781}
782
783fn parse_overwrite(uri: &axum::http::Uri) -> bool {
784 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
785}
786
787fn validate_rel_path(name: &str) -> Result<(), ApiError> {
788 for c in std::path::Path::new(name).components() {
789 match c {
790 Component::Normal(_) => {}
791 _ => {
792 return Err(ApiError::new(
793 StatusCode::BAD_REQUEST,
794 "invalid file path in upload",
795 ));
796 }
797 }
798 }
799 Ok(())
800}
801
802// ---------------------------------------------------------------------------
803// Helpers
804// ---------------------------------------------------------------------------
805
806fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
807 roots
808 .iter()
809 .find(|r| r.id == root_id)
810 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
811}
812
813fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
814 let root = find_root(roots, root_id)?;
815 if !root.mode.is_writable() {
816 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
817 }
818 Ok(root)
819}
820