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