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(|_| {
118 ApiError::localized(
119 StatusCode::INTERNAL_SERVER_ERROR,
120 "internal error",
121 "err_internal",
122 )
123 })??;
124
125 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
126 .await
127 .map_err(|_| {
128 ApiError::localized(
129 StatusCode::INTERNAL_SERVER_ERROR,
130 "internal error",
131 "err_internal",
132 )
133 })??;
134
135 Ok(Json(FilesResp { entries }))
136}
137
138// ---------------------------------------------------------------------------
139// download / preview / content (milestone 4)
140// ---------------------------------------------------------------------------
141
142/// Escape a file name for a `Content-Disposition` header value.
143fn disp_name(name: &str) -> String {
144 name.replace('\\', "\\\\")
145 .replace('"', "\\\"")
146 .replace(['\n', '\r'], "_")
147}
148
149/// Resolve the requested item to an absolute path + metadata (blocking).
150async fn resolve_item(
151 state: &AppState,
152 root: &RootRow,
153 req_rel: &str,
154 share: Option<&ShareRow>,
155) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
156 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
157 // A file share's synthetic root *is* the file, so resolve it directly.
158 let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
159 let inner = tokio::task::spawn_blocking(move || {
160 let full = match share_target {
161 Some(target) => fs::resolve_file(&server_root, &target)?,
162 None => fs::resolve_path(&server_root, &root_rel, &rel)?,
163 };
164 let name = full
165 .file_name()
166 .map(|n| n.to_string_lossy().into_owned())
167 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
168 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
169 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
170 })
171 .await
172 .map_err(|_| {
173 ApiError::localized(
174 StatusCode::INTERNAL_SERVER_ERROR,
175 "internal error",
176 "err_internal",
177 )
178 })?;
179 Ok(inner?)
180}
181
182/// `GET ...?action=download` — a single file as-is, a folder as an archive
183/// (format chosen by the client).
184async fn download(
185 state: Arc<AppState>,
186 auth: AuthUser,
187 root_id: i64,
188 req_rel: String,
189 format: Option<&str>,
190) -> Result<Response, ApiError> {
191 let root = find_root(&auth.roots, root_id)?;
192 let (full, name, is_dir, size) =
193 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
194
195 if !is_dir {
196 return file_response(&full, &name, size, false).await;
197 }
198
199 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
200 ApiError::localized(
201 StatusCode::BAD_REQUEST,
202 "format must be one of: zip, tar, tar.gz, tar.zst",
203 "err_bad_format",
204 )
205 })?;
206 let disp = format!(
207 "attachment; filename=\"{}.{}\"",
208 disp_name(&name),
209 fmt.extension()
210 );
211 let body = stream_archive(fmt, full, name);
212 Response::builder()
213 .status(StatusCode::OK)
214 .header(header::CONTENT_TYPE, fmt.mime())
215 .header(header::CONTENT_DISPOSITION, disp)
216 .body(body)
217 .map_err(|e| {
218 ApiError::new(
219 StatusCode::INTERNAL_SERVER_ERROR,
220 format!("bad response: {e}"),
221 )
222 })
223}
224
225/// `GET ...?action=preview` — a single file, inline (for native media).
226async fn preview(
227 state: Arc<AppState>,
228 auth: AuthUser,
229 root_id: i64,
230 req_rel: String,
231) -> Result<Response, ApiError> {
232 let root = find_root(&auth.roots, root_id)?;
233 let (full, name, is_dir, size) =
234 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
235 if is_dir {
236 return Err(ApiError::localized(
237 StatusCode::BAD_REQUEST,
238 "not a file",
239 "err_not_a_file",
240 ));
241 }
242 file_response(&full, &name, size, true).await
243}
244
245/// `GET ...?action=content` — raw file bytes for the text preview/editor.
246/// Capped at `MAX_TEXT_BYTES`.
247async fn content(
248 state: Arc<AppState>,
249 auth: AuthUser,
250 root_id: i64,
251 req_rel: String,
252) -> Result<Response, ApiError> {
253 let root = find_root(&auth.roots, root_id)?;
254 let (full, _name, is_dir, size) =
255 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
256 if is_dir {
257 return Err(ApiError::localized(
258 StatusCode::BAD_REQUEST,
259 "not a file",
260 "err_not_a_file",
261 ));
262 }
263 if size > MAX_TEXT_BYTES {
264 return Err(ApiError::localized(
265 StatusCode::PAYLOAD_TOO_LARGE,
266 "file too large to preview",
267 "err_too_large_preview",
268 ));
269 }
270 let bytes = tokio::fs::read(&full).await.map_err(|_| {
271 ApiError::localized(
272 StatusCode::INTERNAL_SERVER_ERROR,
273 "internal error",
274 "err_internal",
275 )
276 })?;
277 let meta = tokio::fs::metadata(&full).await.map_err(|_| {
278 ApiError::localized(
279 StatusCode::INTERNAL_SERVER_ERROR,
280 "internal error",
281 "err_internal",
282 )
283 })?;
284 let mtime = fs::mtime_secs(&meta).unwrap_or(0);
285 Ok((
286 [
287 (
288 header::CONTENT_TYPE,
289 "text/plain; charset=utf-8".to_string(),
290 ),
291 (
292 axum::http::HeaderName::from_static("x-file-mtime"),
293 mtime.to_string(),
294 ),
295 ],
296 bytes,
297 )
298 .into_response())
299}
300
301/// `PUT ...?action=content` — save a file's text contents (the editor).
302///
303/// Requires a read-write root. The body is the new contents. If the
304/// `X-Expected-Mtime` header is present, the file's current mtime must match
305/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
306/// Returns the file's new mtime so the client can anchor the next check.
307pub async fn file_put(
308 State(state): State<Arc<AppState>>,
309 auth: AuthUser,
310 path: AxumPath<(i64, String)>,
311 query: AxumQuery<FileQuery>,
312 headers: axum::http::HeaderMap,
313 body: axum::body::Bytes,
314) -> Result<Json<SaveResp>, ApiError> {
315 let (root_id, req_rel) = path.0;
316 put_inner(state, auth, root_id, req_rel, query, headers, body).await
317}
318
319/// `PUT .../{root_id}?action=content` — the root item itself. Only reachable
320/// for a *file* share (its root is the file); for folders it resolves to a
321/// directory and is rejected below.
322pub async fn file_put_root(
323 State(state): State<Arc<AppState>>,
324 auth: AuthUser,
325 path: AxumPath<i64>,
326 query: AxumQuery<FileQuery>,
327 headers: axum::http::HeaderMap,
328 body: axum::body::Bytes,
329) -> Result<Json<SaveResp>, ApiError> {
330 put_inner(state, auth, path.0, String::new(), query, headers, body).await
331}
332
333async fn put_inner(
334 state: Arc<AppState>,
335 auth: AuthUser,
336 root_id: i64,
337 req_rel: String,
338 query: AxumQuery<FileQuery>,
339 headers: axum::http::HeaderMap,
340 body: axum::body::Bytes,
341) -> Result<Json<SaveResp>, ApiError> {
342 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
343 return Err(ApiError::localized(
344 StatusCode::BAD_REQUEST,
345 "expected action=content",
346 "err_bad_action",
347 ));
348 }
349 let root = require_rw_root(&auth.roots, root_id)?;
350 if body.len() as u64 > MAX_TEXT_BYTES {
351 return Err(ApiError::localized(
352 StatusCode::PAYLOAD_TOO_LARGE,
353 "file too large to save",
354 "err_too_large_save",
355 ));
356 }
357 let expected: Option<i64> = headers
358 .get("x-expected-mtime")
359 .and_then(|v| v.to_str().ok())
360 .and_then(|s| s.parse().ok());
361 // A file share's synthetic root *is* the file, so resolve it directly.
362 let share_target = auth
363 .share
364 .as_ref()
365 .filter(|s| s.is_file)
366 .map(|s| s.target.clone());
367 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
368 let content = body.to_vec();
369 let mtime = tokio::task::spawn_blocking(move || match share_target {
370 Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
371 None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
372 })
373 .await
374 .map_err(|_| {
375 ApiError::localized(
376 StatusCode::INTERNAL_SERVER_ERROR,
377 "internal error",
378 "err_internal",
379 )
380 })??;
381 Ok(Json(SaveResp { ok: true, mtime }))
382}
383
384/// Stream a single file to the client with the right disposition.
385async fn file_response(
386 full: &std::path::Path,
387 name: &str,
388 size: u64,
389 inline: bool,
390) -> Result<Response, ApiError> {
391 let mime = mime_guess::from_path(full)
392 .first_or_octet_stream()
393 .to_string();
394 let disp = if inline {
395 format!("inline; filename=\"{}\"", disp_name(name))
396 } else {
397 format!("attachment; filename=\"{}\"", disp_name(name))
398 };
399 let body = stream_file(full.to_path_buf());
400 let mut res = Response::builder()
401 .status(StatusCode::OK)
402 .header(header::CONTENT_DISPOSITION, disp)
403 .header(header::CONTENT_LENGTH, size);
404 // A file the browser would parse as a document (HTML/SVG/XML) is served
405 // under the sandboxed policy, so it can render as a page without being
406 // able to act as the app. Derived from the same `mime` we declare.
407 // Non-scriptable inline files (PDF, …) are frameable by the app itself,
408 // for the preview modal.
409 if crate::api::is_scriptable_mime(&mime) {
410 res = res.header("content-security-policy", crate::api::FILE_CSP);
411 } else if inline {
412 res = res
413 .header("content-security-policy", crate::api::INLINE_CSP)
414 .header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
415 }
416 res.header(header::CONTENT_TYPE, mime)
417 .body(body)
418 .map_err(|e| {
419 ApiError::new(
420 StatusCode::INTERNAL_SERVER_ERROR,
421 format!("bad response: {e}"),
422 )
423 })
424}
425
426/// Stream `path` to the client in chunks (blocking reader → channel).
427fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
428 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
429 tokio::task::spawn_blocking(move || {
430 let mut f = match std::fs::File::open(&path) {
431 Ok(f) => f,
432 Err(e) => {
433 tracing::warn!(error = %e, path = %path.display(), "download open failed");
434 return;
435 }
436 };
437 let mut buf = vec![0u8; 256 * 1024];
438 loop {
439 match f.read(&mut buf) {
440 Ok(0) => break,
441 Ok(n) => {
442 // Client gone → stop producing.
443 if tx.blocking_send(buf[..n].to_vec()).is_err() {
444 break;
445 }
446 }
447 Err(e) => {
448 tracing::warn!(error = %e, path = %path.display(), "download read failed");
449 break;
450 }
451 }
452 }
453 });
454 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
455 axum::body::Body::from_stream(stream)
456}
457
458/// Stream an archive of `dir` (top-level entry `top`) to the client.
459fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
460 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
461 tokio::task::spawn_blocking(move || {
462 let mut sink = ChanWriter::new(tx);
463 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
464 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
465 }
466 // Dropping the sink flushes its buffer and closes the channel.
467 });
468 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
469 axum::body::Body::from_stream(stream)
470}
471
472/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
473/// bridge between the blocking archive builder and the async response body.
474struct ChanWriter {
475 tx: mpsc::Sender<Vec<u8>>,
476 buf: Vec<u8>,
477}
478
479impl ChanWriter {
480 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
481 Self {
482 tx,
483 buf: Vec::with_capacity(64 * 1024),
484 }
485 }
486}
487
488impl io::Write for ChanWriter {
489 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
490 self.buf.extend_from_slice(b);
491 if self.buf.len() >= 64 * 1024 {
492 io::Write::flush(self)?;
493 }
494 Ok(b.len())
495 }
496 fn flush(&mut self) -> io::Result<()> {
497 if !self.buf.is_empty() {
498 let chunk = std::mem::take(&mut self.buf);
499 self.tx
500 .blocking_send(chunk)
501 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
502 }
503 Ok(())
504 }
505}
506
507impl Drop for ChanWriter {
508 fn drop(&mut self) {
509 let _ = io::Write::flush(self);
510 }
511}
512
513// ---------------------------------------------------------------------------
514// POST dispatch: mkdir | rename/move/copy | upload
515// ---------------------------------------------------------------------------
516
517/// POST /api/files/{root_id} — top-level operations (upload into the root
518/// directory). The bare root never targets a specific item, so JSON ops with
519/// a missing folder name are rejected by the individual handlers.
520pub async fn dispatch_root(
521 State(state): State<Arc<AppState>>,
522 auth: AuthUser,
523 path: AxumPath<i64>,
524 headers: axum::http::HeaderMap,
525 req: axum::http::Request<axum::body::Body>,
526) -> Result<Response, ApiError> {
527 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
528}
529
530/// POST /api/files/{root_id}/{*path}
531pub async fn dispatch(
532 State(state): State<Arc<AppState>>,
533 auth: AuthUser,
534 path: AxumPath<(i64, String)>,
535 headers: axum::http::HeaderMap,
536 req: axum::http::Request<axum::body::Body>,
537) -> Result<Response, ApiError> {
538 let (root_id, req_rel) = path.0;
539 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
540}
541
542/// Route one POST to upload, mutation or mkdir.
543///
544/// Upload and mutation are recognized by their content type. mkdir carries no
545/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
546/// an unrecognized content type used to fall through to mkdir, which turned a
547/// typo in a header into a silently created folder.
548async fn dispatch_inner(
549 state: Arc<AppState>,
550 auth: AuthUser,
551 root_id: i64,
552 req_rel: String,
553 headers: axum::http::HeaderMap,
554 req: axum::http::Request<axum::body::Body>,
555) -> Result<Response, ApiError> {
556 let ct = headers
557 .get(header::CONTENT_TYPE)
558 .and_then(|v| v.to_str().ok())
559 .unwrap_or("");
560
561 if ct.starts_with("multipart/form-data") {
562 return upload(state, auth, root_id, req_rel, req).await;
563 }
564 if ct.starts_with("application/json") {
565 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
566 .await
567 .map_err(|_| {
568 ApiError::localized(
569 StatusCode::BAD_REQUEST,
570 "invalid request body",
571 "err_bad_body",
572 )
573 })?;
574 let body: Mutation = axum::Json::from_bytes(&bytes)
575 .map_err(|_| {
576 ApiError::localized(
577 StatusCode::BAD_REQUEST,
578 "invalid request body",
579 "err_bad_body",
580 )
581 })?
582 .0;
583 return Ok(mutation(state, auth, root_id, req_rel, body)
584 .await?
585 .into_response());
586 }
587 if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
588 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
589 }
590 Err(ApiError::localized(
591 StatusCode::UNSUPPORTED_MEDIA_TYPE,
592 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
593 "err_bad_post",
594 ))
595}
596
597// ---------------------------------------------------------------------------
598// mkdir
599// ---------------------------------------------------------------------------
600
601async fn mkdir(
602 state: Arc<AppState>,
603 auth: AuthUser,
604 root_id: i64,
605 req_rel: String,
606) -> Result<Json<OkResp>, ApiError> {
607 let root = require_rw_root(&auth.roots, root_id)?;
608 if req_rel.trim().is_empty() {
609 return Err(ApiError::localized(
610 StatusCode::BAD_REQUEST,
611 "a folder name is required",
612 "err_folder_name_required",
613 ));
614 }
615 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
616 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
617 .await
618 .map_err(|_| {
619 ApiError::localized(
620 StatusCode::INTERNAL_SERVER_ERROR,
621 "internal error",
622 "err_internal",
623 )
624 })??;
625 Ok(Json(OkResp { ok: true }))
626}
627
628// ---------------------------------------------------------------------------
629// rename / move / copy
630// ---------------------------------------------------------------------------
631
632async fn mutation(
633 state: Arc<AppState>,
634 auth: AuthUser,
635 root_id: i64,
636 req_rel: String,
637 body: Mutation,
638) -> Result<Json<OkResp>, ApiError> {
639 match body.op {
640 Op::Rename => {
641 let new_name = body
642 .new_name
643 .as_deref()
644 .ok_or_else(|| {
645 ApiError::localized(
646 StatusCode::BAD_REQUEST,
647 "new_name is required",
648 "err_new_name_required",
649 )
650 })?
651 .to_string();
652 let root = require_rw_root(&auth.roots, root_id)?;
653 let (server_root, root_rel, rel, overwrite) = (
654 state.root.clone(),
655 root.path.clone(),
656 req_rel,
657 body.overwrite,
658 );
659 tokio::task::spawn_blocking(move || {
660 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
661 })
662 .await
663 .map_err(|_| {
664 ApiError::localized(
665 StatusCode::INTERNAL_SERVER_ERROR,
666 "internal error",
667 "err_internal",
668 )
669 })??;
670 Ok(Json(OkResp { ok: true }))
671 }
672 Op::Move | Op::Copy => {
673 let dst_root_id = body.dst_root_id.ok_or_else(|| {
674 ApiError::localized(
675 StatusCode::BAD_REQUEST,
676 "dst_root_id is required",
677 "err_dst_required",
678 )
679 })?;
680 let dst = body.dst.clone().unwrap_or_default();
681 // Moving or copying out of a folder requires rw there; copying
682 // *from* a read-only root is fine.
683 let op_is_move = body.op == Op::Move;
684 let src_root = if op_is_move {
685 require_rw_root(&auth.roots, root_id)?
686 } else {
687 find_root(&auth.roots, root_id)?
688 };
689 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
690 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
691 state.root.clone(),
692 src_root.path.clone(),
693 dst_root.path.clone(),
694 dst,
695 req_rel,
696 body.overwrite,
697 );
698 tokio::task::spawn_blocking(move || {
699 if op_is_move {
700 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
701 } else {
702 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
703 }
704 })
705 .await
706 .map_err(|_| {
707 ApiError::localized(
708 StatusCode::INTERNAL_SERVER_ERROR,
709 "internal error",
710 "err_internal",
711 )
712 })??;
713 Ok(Json(OkResp { ok: true }))
714 }
715 }
716}
717
718// ---------------------------------------------------------------------------
719// DELETE
720// ---------------------------------------------------------------------------
721
722pub async fn delete(
723 State(state): State<Arc<AppState>>,
724 auth: AuthUser,
725 path: AxumPath<(i64, String)>,
726) -> Result<Json<serde_json::Value>, ApiError> {
727 let (root_id, req_rel) = path.0;
728 let root = require_rw_root(&auth.roots, root_id)?;
729 if req_rel.trim().is_empty() {
730 return Err(ApiError::localized(
731 StatusCode::BAD_REQUEST,
732 "a path inside the folder is required",
733 "err_path_required",
734 ));
735 }
736 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
737 let is_dir =
738 tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
739 .await
740 .map_err(|_| {
741 ApiError::localized(
742 StatusCode::INTERNAL_SERVER_ERROR,
743 "internal error",
744 "err_internal",
745 )
746 })??;
747 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
748}
749
750// ---------------------------------------------------------------------------
751// Upload (multipart)
752// ---------------------------------------------------------------------------
753
754async fn upload(
755 state: Arc<AppState>,
756 auth: AuthUser,
757 root_id: i64,
758 req_rel: String,
759 req: axum::http::Request<axum::body::Body>,
760) -> Result<Response, ApiError> {
761 let root = require_rw_root(&auth.roots, root_id)?;
762 let base = {
763 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
764 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
765 .await
766 .map_err(|_| {
767 ApiError::localized(
768 StatusCode::INTERNAL_SERVER_ERROR,
769 "internal error",
770 "err_internal",
771 )
772 })??
773 };
774 let boundary = req
775 .headers()
776 .get(header::CONTENT_TYPE)
777 .and_then(|v| v.to_str().ok())
778 .and_then(parse_boundary)
779 .ok_or_else(|| {
780 ApiError::localized(
781 StatusCode::BAD_REQUEST,
782 "expected multipart/form-data with a boundary",
783 "err_bad_multipart",
784 )
785 })?;
786 let overwrite = parse_overwrite(req.uri());
787
788 let stream = req.into_body().into_data_stream();
789 let mut multipart = Multipart::new(stream, boundary);
790 let mut uploaded: usize = 0;
791 let mut skipped: Vec<String> = Vec::new();
792
793 while let Some(mut field) = multipart.next_field().await.map_err(|_| {
794 ApiError::localized(
795 StatusCode::BAD_REQUEST,
796 "invalid upload data",
797 "err_bad_upload",
798 )
799 })? {
800 let part_name = field
801 .name()
802 .filter(|n| !n.is_empty())
803 .or_else(|| field.file_name())
804 .map(str::to_string)
805 .ok_or_else(|| {
806 ApiError::localized(
807 StatusCode::BAD_REQUEST,
808 "part without a name",
809 "err_part_no_name",
810 )
811 })?;
812
813 validate_rel_path(&part_name)?;
814
815 let target = base.join(&part_name);
816 let parent = target
817 .parent()
818 .filter(|p| !p.as_os_str().is_empty())
819 .ok_or_else(|| {
820 ApiError::localized(
821 StatusCode::BAD_REQUEST,
822 "invalid part name",
823 "err_bad_part_name",
824 )
825 })?;
826 if !parent.is_dir() {
827 let p = parent.to_path_buf();
828 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
829 .await
830 .map_err(|_| {
831 ApiError::localized(
832 StatusCode::INTERNAL_SERVER_ERROR,
833 "internal error",
834 "err_internal",
835 )
836 })??;
837 }
838
839 if target.exists() && !overwrite {
840 // Drain this part and report it as a conflict at the end.
841 while let Some(_chunk) = field.chunk().await.map_err(|_| {
842 ApiError::localized(
843 StatusCode::BAD_REQUEST,
844 "invalid upload data",
845 "err_bad_upload",
846 )
847 })? {}
848 skipped.push(part_name);
849 continue;
850 }
851 if target.exists() {
852 if target.is_dir() {
853 return Err(ApiError::localized(
854 StatusCode::CONFLICT,
855 "a folder with this name already exists",
856 "err_folder_exists",
857 ));
858 }
859 tokio::fs::remove_file(&target).await.map_err(|_| {
860 ApiError::localized(
861 StatusCode::INTERNAL_SERVER_ERROR,
862 "internal error",
863 "err_internal",
864 )
865 })?;
866 }
867
868 // Stream to a temp file in the same directory, then rename into place.
869 let suffix = crate::auth::random_token();
870 let tmp = parent.join(format!(".upload-{suffix}"));
871 let mut tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
872 ApiError::localized(
873 StatusCode::INTERNAL_SERVER_ERROR,
874 "internal error",
875 "err_internal",
876 )
877 })?;
878 let write_failed = loop {
879 match field.chunk().await.map_err(|_| {
880 ApiError::localized(
881 StatusCode::BAD_REQUEST,
882 "invalid upload data",
883 "err_bad_upload",
884 )
885 }) {
886 Ok(Some(chunk)) => {
887 if let Err(e) = tmp_file.write_all(&chunk).await {
888 tracing::warn!(error = %e, "write failed during upload");
889 break true;
890 }
891 }
892 Ok(None) => break false,
893 Err(e) => return Err(e),
894 }
895 };
896 if write_failed {
897 let _ = tokio::fs::remove_file(&tmp).await;
898 return Err(ApiError::localized(
899 StatusCode::INTERNAL_SERVER_ERROR,
900 "could not save the file",
901 "err_save_failed",
902 ));
903 }
904 let tmp2 = tmp.clone();
905 let target2 = target.clone();
906 let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
907 .await
908 .map_err(|_| {
909 ApiError::localized(
910 StatusCode::INTERNAL_SERVER_ERROR,
911 "internal error",
912 "err_internal",
913 )
914 })?;
915 renamed.map_err(|e| {
916 let _ = std::fs::remove_file(&tmp);
917 tracing::warn!(error = %e, "rename failed during upload");
918 ApiError::localized(
919 StatusCode::INTERNAL_SERVER_ERROR,
920 "internal error",
921 "err_internal",
922 )
923 })?;
924 uploaded += 1;
925 }
926
927 if uploaded == 0 && skipped.is_empty() {
928 return Err(ApiError::localized(
929 StatusCode::BAD_REQUEST,
930 "no files were uploaded",
931 "err_no_files_uploaded",
932 ));
933 }
934 if !skipped.is_empty() {
935 return Err(ApiError::localized(
936 StatusCode::CONFLICT,
937 "some files already exist",
938 "err_files_exist",
939 )
940 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
941 }
942 Ok(Json(UploadResp { ok: true, uploaded }).into_response())
943}
944
945fn parse_boundary(content_type: &str) -> Option<String> {
946 content_type
947 .split(';')
948 .map(|s| s.trim())
949 .find_map(|s| s.strip_prefix("boundary="))
950 .map(|b| b.trim_matches('"').to_string())
951 .filter(|b| !b.is_empty())
952}
953
954/// Read one query parameter from the request URI.
955fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
956 uri.query()?.split('&').find_map(|kv| {
957 let (k, v) = kv.split_once('=')?;
958 (k == key).then(|| v.to_string())
959 })
960}
961
962fn action_param(uri: &axum::http::Uri) -> Option<String> {
963 query_param(uri, P_ACTION)
964}
965
966fn parse_overwrite(uri: &axum::http::Uri) -> bool {
967 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
968}
969
970fn validate_rel_path(name: &str) -> Result<(), ApiError> {
971 for c in std::path::Path::new(name).components() {
972 match c {
973 Component::Normal(_) => {}
974 _ => {
975 return Err(ApiError::localized(
976 StatusCode::BAD_REQUEST,
977 "invalid file path in upload",
978 "err_bad_upload_path",
979 ));
980 }
981 }
982 }
983 Ok(())
984}
985
986// ---------------------------------------------------------------------------
987// Helpers
988// ---------------------------------------------------------------------------
989
990fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
991 roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
992 ApiError::localized(
993 StatusCode::FORBIDDEN,
994 "no such folder",
995 "err_no_such_folder",
996 )
997 })
998}
999
1000fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1001 let root = find_root(roots, root_id)?;
1002 if !root.mode.is_writable() {
1003 return Err(ApiError::localized(
1004 StatusCode::FORBIDDEN,
1005 "read-only folder",
1006 "err_read_only_folder",
1007 ));
1008 }
1009 Ok(root)
1010}
1011