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::{FilesResp, Mutation, OkResp, Op, P_OVERWRITE, SaveResp, UploadResp};
34
35/// Upper bound for the in-memory text endpoint (preview, later editor).
36const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
37
38// ---------------------------------------------------------------------------
39// Query params
40// ---------------------------------------------------------------------------
41
42/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
43/// route lists the directory; `?action=download|preview|content` serve the
44/// item itself.
45#[derive(Deserialize, Default)]
46pub struct FileQuery {
47 #[serde(default)]
48 action: Option<String>,
49 #[serde(default)]
50 format: Option<String>,
51}
52
53// ---------------------------------------------------------------------------
54// Listing
55// ---------------------------------------------------------------------------
56
57/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
58/// itself via `?action=download|preview|content`.
59pub async fn file_get(
60 State(state): State<Arc<AppState>>,
61 auth: AuthUser,
62 path: AxumPath<(i64, String)>,
63 query: AxumQuery<FileQuery>,
64) -> Result<Response, ApiError> {
65 let (root_id, req_rel) = path.0;
66 match query.action.as_deref() {
67 Some(a) if a == api_types::ACTION_DOWNLOAD => {
68 download(state, auth, root_id, req_rel, query.format.as_deref()).await
69 }
70 Some(a) if a == api_types::ACTION_PREVIEW => preview(state, auth, root_id, req_rel).await,
71 Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
72 _ => {
73 let json = list_inner(state, auth, root_id, req_rel).await?;
74 Ok(json.into_response())
75 }
76 }
77}
78
79/// GET /api/files/{root_id} — list the root directory itself, or serve the
80/// root item via `?action=download|preview|content` (the root of a *file*
81/// share is the file itself).
82pub async fn list_root(
83 State(state): State<Arc<AppState>>,
84 auth: AuthUser,
85 path: AxumPath<i64>,
86 query: AxumQuery<FileQuery>,
87) -> Result<Response, ApiError> {
88 let root_id = path.0;
89 match query.action.as_deref() {
90 Some(a) if a == api_types::ACTION_DOWNLOAD => {
91 download(state, auth, root_id, String::new(), query.format.as_deref()).await
92 }
93 Some(a) if a == api_types::ACTION_PREVIEW => {
94 preview(state, auth, root_id, String::new()).await
95 }
96 Some(a) if a == api_types::ACTION_CONTENT => {
97 content(state, auth, root_id, String::new()).await
98 }
99 _ => {
100 let json = list_inner(state, auth, root_id, String::new()).await?;
101 Ok(json.into_response())
102 }
103 }
104}
105
106async fn list_inner(
107 state: Arc<AppState>,
108 auth: AuthUser,
109 root_id: i64,
110 req_rel: String,
111) -> Result<Json<FilesResp>, ApiError> {
112 let root = find_root(&auth.roots, root_id)?;
113 let server_root = state.root.clone();
114 let root_rel = root.path.clone();
115 let full =
116 tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
117 .await
118 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
119
120 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
121 .await
122 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
123
124 Ok(Json(FilesResp { entries }))
125}
126
127// ---------------------------------------------------------------------------
128// download / preview / content (milestone 4)
129// ---------------------------------------------------------------------------
130
131/// Escape a file name for a `Content-Disposition` header value.
132fn disp_name(name: &str) -> String {
133 name.replace('\\', "\\\\")
134 .replace('"', "\\\"")
135 .replace(['\n', '\r'], "_")
136}
137
138/// Resolve the requested item to an absolute path + metadata (blocking).
139async fn resolve_item(
140 state: &AppState,
141 root: &RootRow,
142 req_rel: &str,
143 share: Option<&ShareRow>,
144) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
145 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
146 // A file share's synthetic root *is* the file, so resolve it directly.
147 let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
148 let inner = tokio::task::spawn_blocking(move || {
149 let full = match share_target {
150 Some(target) => fs::resolve_file(&server_root, &target)?,
151 None => fs::resolve_path(&server_root, &root_rel, &rel)?,
152 };
153 let name = full
154 .file_name()
155 .map(|n| n.to_string_lossy().into_owned())
156 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
157 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
158 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
159 })
160 .await
161 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
162 Ok(inner?)
163}
164
165/// `GET ...?action=download` — a single file as-is, a folder as an archive
166/// (format chosen by the client).
167async fn download(
168 state: Arc<AppState>,
169 auth: AuthUser,
170 root_id: i64,
171 req_rel: String,
172 format: Option<&str>,
173) -> Result<Response, ApiError> {
174 let root = find_root(&auth.roots, root_id)?;
175 let (full, name, is_dir, size) =
176 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
177
178 if !is_dir {
179 return file_response(&full, &name, size, false).await;
180 }
181
182 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
183 ApiError::new(
184 StatusCode::BAD_REQUEST,
185 "format must be one of: zip, tar, tar.gz, tar.zst",
186 )
187 })?;
188 let disp = format!(
189 "attachment; filename=\"{}.{}\"",
190 disp_name(&name),
191 fmt.extension()
192 );
193 let body = stream_archive(fmt, full, name);
194 Response::builder()
195 .status(StatusCode::OK)
196 .header(header::CONTENT_TYPE, fmt.mime())
197 .header(header::CONTENT_DISPOSITION, disp)
198 .body(body)
199 .map_err(|e| {
200 ApiError::new(
201 StatusCode::INTERNAL_SERVER_ERROR,
202 format!("bad response: {e}"),
203 )
204 })
205}
206
207/// `GET ...?action=preview` — a single file, inline (for native media).
208async fn preview(
209 state: Arc<AppState>,
210 auth: AuthUser,
211 root_id: i64,
212 req_rel: String,
213) -> Result<Response, ApiError> {
214 let root = find_root(&auth.roots, root_id)?;
215 let (full, name, is_dir, size) =
216 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
217 if is_dir {
218 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
219 }
220 file_response(&full, &name, size, true).await
221}
222
223/// `GET ...?action=content` — raw file bytes for the text preview/editor.
224/// Capped at `MAX_TEXT_BYTES`.
225async fn content(
226 state: Arc<AppState>,
227 auth: AuthUser,
228 root_id: i64,
229 req_rel: String,
230) -> Result<Response, ApiError> {
231 let root = find_root(&auth.roots, root_id)?;
232 let (full, _name, is_dir, size) =
233 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
234 if is_dir {
235 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
236 }
237 if size > MAX_TEXT_BYTES {
238 return Err(ApiError::new(
239 StatusCode::PAYLOAD_TOO_LARGE,
240 "file too large to preview",
241 ));
242 }
243 let bytes = tokio::fs::read(&full)
244 .await
245 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
246 let meta = tokio::fs::metadata(&full)
247 .await
248 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
249 let mtime = meta
250 .modified()
251 .ok()
252 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
253 .map(|d| d.as_secs())
254 .unwrap_or(0);
255 Ok((
256 [
257 (
258 header::CONTENT_TYPE,
259 "text/plain; charset=utf-8".to_string(),
260 ),
261 (
262 axum::http::HeaderName::from_static("x-file-mtime"),
263 mtime.to_string(),
264 ),
265 ],
266 bytes,
267 )
268 .into_response())
269}
270
271/// `PUT ...?action=content` — save a file's text contents (the editor).
272///
273/// Requires a read-write root. The body is the new contents. If the
274/// `X-Expected-Mtime` header is present, the file's current mtime must match
275/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
276/// Returns the file's new mtime so the client can anchor the next check.
277pub async fn file_put(
278 State(state): State<Arc<AppState>>,
279 auth: AuthUser,
280 path: AxumPath<(i64, String)>,
281 query: AxumQuery<FileQuery>,
282 headers: axum::http::HeaderMap,
283 body: axum::body::Bytes,
284) -> Result<Json<SaveResp>, ApiError> {
285 let (root_id, req_rel) = path.0;
286 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
287 return Err(ApiError::new(
288 StatusCode::BAD_REQUEST,
289 "expected action=content",
290 ));
291 }
292 let root = require_rw_root(&auth.roots, root_id)?;
293 if body.len() as u64 > MAX_TEXT_BYTES {
294 return Err(ApiError::new(
295 StatusCode::PAYLOAD_TOO_LARGE,
296 "file too large to save",
297 ));
298 }
299 let expected: Option<i64> = headers
300 .get("x-expected-mtime")
301 .and_then(|v| v.to_str().ok())
302 .and_then(|s| s.parse().ok());
303 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
304 let content = body.to_vec();
305 let mtime = tokio::task::spawn_blocking(move || {
306 fs::save_file(&server_root, &root_rel, &rel, &content, expected)
307 })
308 .await
309 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
310 Ok(Json(SaveResp { ok: true, mtime }))
311}
312
313/// Stream a single file to the client with the right disposition.
314async fn file_response(
315 full: &std::path::Path,
316 name: &str,
317 size: u64,
318 inline: bool,
319) -> Result<Response, ApiError> {
320 let mime = mime_guess::from_path(full)
321 .first_or_octet_stream()
322 .to_string();
323 let disp = if inline {
324 format!("inline; filename=\"{}\"", disp_name(name))
325 } else {
326 format!("attachment; filename=\"{}\"", disp_name(name))
327 };
328 let body = stream_file(full.to_path_buf());
329 let mut res = Response::builder()
330 .status(StatusCode::OK)
331 .header(header::CONTENT_DISPOSITION, disp)
332 .header(header::CONTENT_LENGTH, size);
333 // A file the browser would parse as a document (HTML/SVG/XML) is served
334 // under the sandboxed policy, so it can render as a page without being
335 // able to act as the app. Derived from the same `mime` we declare.
336 if crate::api::is_scriptable_mime(&mime) {
337 res = res.header("content-security-policy", crate::api::FILE_CSP);
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
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 Ok(mkdir(state, auth, root_id, req_rel).await?.into_response())
493}
494
495// ---------------------------------------------------------------------------
496// mkdir
497// ---------------------------------------------------------------------------
498
499async fn mkdir(
500 state: Arc<AppState>,
501 auth: AuthUser,
502 root_id: i64,
503 req_rel: String,
504) -> Result<Json<OkResp>, ApiError> {
505 let root = require_rw_root(&auth.roots, root_id)?;
506 if req_rel.trim().is_empty() {
507 return Err(ApiError::new(
508 StatusCode::BAD_REQUEST,
509 "a folder name is required",
510 ));
511 }
512 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
513 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
514 .await
515 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
516 Ok(Json(OkResp { ok: true }))
517}
518
519// ---------------------------------------------------------------------------
520// rename / move / copy
521// ---------------------------------------------------------------------------
522
523async fn mutation(
524 state: Arc<AppState>,
525 auth: AuthUser,
526 root_id: i64,
527 req_rel: String,
528 body: Mutation,
529) -> Result<Json<OkResp>, ApiError> {
530 match body.op {
531 Op::Rename => {
532 let new_name = body
533 .new_name
534 .as_deref()
535 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
536 .to_string();
537 let root = require_rw_root(&auth.roots, root_id)?;
538 let (server_root, root_rel, rel, overwrite) = (
539 state.root.clone(),
540 root.path.clone(),
541 req_rel,
542 body.overwrite,
543 );
544 tokio::task::spawn_blocking(move || {
545 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
546 })
547 .await
548 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
549 Ok(Json(OkResp { ok: true }))
550 }
551 Op::Move | Op::Copy => {
552 let dst_root_id = body
553 .dst_root_id
554 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
555 let dst = body.dst.clone().unwrap_or_default();
556 // Moving or copying out of a folder requires rw there; copying
557 // *from* a read-only root is fine.
558 let op_is_move = body.op == Op::Move;
559 let src_root = if op_is_move {
560 require_rw_root(&auth.roots, root_id)?
561 } else {
562 find_root(&auth.roots, root_id)?
563 };
564 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
565 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
566 state.root.clone(),
567 src_root.path.clone(),
568 dst_root.path.clone(),
569 dst,
570 req_rel,
571 body.overwrite,
572 );
573 tokio::task::spawn_blocking(move || {
574 if op_is_move {
575 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
576 } else {
577 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
578 }
579 })
580 .await
581 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
582 Ok(Json(OkResp { ok: true }))
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.is_writable() {
803 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
804 }
805 Ok(root)
806}
807