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