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 match action_param(req.uri()).as_deref() {
588 Some(api_types::ACTION_MKDIR) => {
589 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
590 }
591 Some(api_types::ACTION_CREATE_FILE) => {
592 return Ok(create_file(state, auth, root_id, req_rel)
593 .await?
594 .into_response());
595 }
596 _ => {}
597 }
598 Err(ApiError::localized(
599 StatusCode::UNSUPPORTED_MEDIA_TYPE,
600 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
601 "err_bad_post",
602 ))
603}
604
605// ---------------------------------------------------------------------------
606// create file
607// ---------------------------------------------------------------------------
608
609/// Create an empty file in a writable root.
610async fn create_file(
611 state: Arc<AppState>,
612 auth: AuthUser,
613 root_id: i64,
614 req_rel: String,
615) -> Result<Json<OkResp>, ApiError> {
616 let root = require_rw_root(&auth.roots, root_id)?;
617 if req_rel.trim().is_empty() {
618 return Err(ApiError::localized(
619 StatusCode::BAD_REQUEST,
620 "a file name is required",
621 "err_file_name_required",
622 ));
623 }
624 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
625 tokio::task::spawn_blocking(move || fs::create_file(&server_root, &root_rel, &rel))
626 .await
627 .map_err(|_| {
628 ApiError::localized(
629 StatusCode::INTERNAL_SERVER_ERROR,
630 "internal error",
631 "err_internal",
632 )
633 })??;
634 Ok(Json(OkResp { ok: true }))
635}
636
637// ---------------------------------------------------------------------------
638// mkdir
639// ---------------------------------------------------------------------------
640
641async fn mkdir(
642 state: Arc<AppState>,
643 auth: AuthUser,
644 root_id: i64,
645 req_rel: String,
646) -> Result<Json<OkResp>, ApiError> {
647 let root = require_rw_root(&auth.roots, root_id)?;
648 if req_rel.trim().is_empty() {
649 return Err(ApiError::localized(
650 StatusCode::BAD_REQUEST,
651 "a folder name is required",
652 "err_folder_name_required",
653 ));
654 }
655 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
656 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
657 .await
658 .map_err(|_| {
659 ApiError::localized(
660 StatusCode::INTERNAL_SERVER_ERROR,
661 "internal error",
662 "err_internal",
663 )
664 })??;
665 Ok(Json(OkResp { ok: true }))
666}
667
668// ---------------------------------------------------------------------------
669// rename / move / copy
670// ---------------------------------------------------------------------------
671
672async fn mutation(
673 state: Arc<AppState>,
674 auth: AuthUser,
675 root_id: i64,
676 req_rel: String,
677 body: Mutation,
678) -> Result<Json<OkResp>, ApiError> {
679 match body.op {
680 Op::Rename => {
681 let new_name = body
682 .new_name
683 .as_deref()
684 .ok_or_else(|| {
685 ApiError::localized(
686 StatusCode::BAD_REQUEST,
687 "new_name is required",
688 "err_new_name_required",
689 )
690 })?
691 .to_string();
692 let root = require_rw_root(&auth.roots, root_id)?;
693 let (server_root, root_rel, rel, overwrite) = (
694 state.root.clone(),
695 root.path.clone(),
696 req_rel,
697 body.overwrite,
698 );
699 tokio::task::spawn_blocking(move || {
700 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
701 })
702 .await
703 .map_err(|_| {
704 ApiError::localized(
705 StatusCode::INTERNAL_SERVER_ERROR,
706 "internal error",
707 "err_internal",
708 )
709 })??;
710 Ok(Json(OkResp { ok: true }))
711 }
712 Op::Move | Op::Copy => {
713 let dst_root_id = body.dst_root_id.ok_or_else(|| {
714 ApiError::localized(
715 StatusCode::BAD_REQUEST,
716 "dst_root_id is required",
717 "err_dst_required",
718 )
719 })?;
720 let dst = body.dst.clone().unwrap_or_default();
721 // Moving or copying out of a folder requires rw there; copying
722 // *from* a read-only root is fine.
723 let op_is_move = body.op == Op::Move;
724 let src_root = if op_is_move {
725 require_rw_root(&auth.roots, root_id)?
726 } else {
727 find_root(&auth.roots, root_id)?
728 };
729 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
730 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
731 state.root.clone(),
732 src_root.path.clone(),
733 dst_root.path.clone(),
734 dst,
735 req_rel,
736 body.overwrite,
737 );
738 tokio::task::spawn_blocking(move || {
739 if op_is_move {
740 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
741 } else {
742 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
743 }
744 })
745 .await
746 .map_err(|_| {
747 ApiError::localized(
748 StatusCode::INTERNAL_SERVER_ERROR,
749 "internal error",
750 "err_internal",
751 )
752 })??;
753 Ok(Json(OkResp { ok: true }))
754 }
755 }
756}
757
758// ---------------------------------------------------------------------------
759// DELETE
760// ---------------------------------------------------------------------------
761
762pub async fn delete(
763 State(state): State<Arc<AppState>>,
764 auth: AuthUser,
765 path: AxumPath<(i64, String)>,
766) -> Result<Json<serde_json::Value>, ApiError> {
767 let (root_id, req_rel) = path.0;
768 let root = require_rw_root(&auth.roots, root_id)?;
769 if req_rel.trim().is_empty() {
770 return Err(ApiError::localized(
771 StatusCode::BAD_REQUEST,
772 "a path inside the folder is required",
773 "err_path_required",
774 ));
775 }
776 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
777 let is_dir =
778 tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
779 .await
780 .map_err(|_| {
781 ApiError::localized(
782 StatusCode::INTERNAL_SERVER_ERROR,
783 "internal error",
784 "err_internal",
785 )
786 })??;
787 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
788}
789
790// ---------------------------------------------------------------------------
791// Upload (multipart)
792// ---------------------------------------------------------------------------
793
794async fn upload(
795 state: Arc<AppState>,
796 auth: AuthUser,
797 root_id: i64,
798 req_rel: String,
799 req: axum::http::Request<axum::body::Body>,
800) -> Result<Response, ApiError> {
801 let root = require_rw_root(&auth.roots, root_id)?;
802 let base = {
803 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
804 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
805 .await
806 .map_err(|_| {
807 ApiError::localized(
808 StatusCode::INTERNAL_SERVER_ERROR,
809 "internal error",
810 "err_internal",
811 )
812 })??
813 };
814 let boundary = req
815 .headers()
816 .get(header::CONTENT_TYPE)
817 .and_then(|v| v.to_str().ok())
818 .and_then(parse_boundary)
819 .ok_or_else(|| {
820 ApiError::localized(
821 StatusCode::BAD_REQUEST,
822 "expected multipart/form-data with a boundary",
823 "err_bad_multipart",
824 )
825 })?;
826 let overwrite = parse_overwrite(req.uri());
827
828 let stream = req.into_body().into_data_stream();
829 let mut multipart = Multipart::new(stream, boundary);
830 let mut uploaded: usize = 0;
831 let mut skipped: Vec<String> = Vec::new();
832
833 while let Some(mut field) = multipart.next_field().await.map_err(|_| {
834 ApiError::localized(
835 StatusCode::BAD_REQUEST,
836 "invalid upload data",
837 "err_bad_upload",
838 )
839 })? {
840 let part_name = field
841 .name()
842 .filter(|n| !n.is_empty())
843 .or_else(|| field.file_name())
844 .map(str::to_string)
845 .ok_or_else(|| {
846 ApiError::localized(
847 StatusCode::BAD_REQUEST,
848 "part without a name",
849 "err_part_no_name",
850 )
851 })?;
852
853 validate_rel_path(&part_name)?;
854
855 let target = base.join(&part_name);
856 let parent = target
857 .parent()
858 .filter(|p| !p.as_os_str().is_empty())
859 .ok_or_else(|| {
860 ApiError::localized(
861 StatusCode::BAD_REQUEST,
862 "invalid part name",
863 "err_bad_part_name",
864 )
865 })?;
866 if !parent.is_dir() {
867 let p = parent.to_path_buf();
868 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
869 .await
870 .map_err(|_| {
871 ApiError::localized(
872 StatusCode::INTERNAL_SERVER_ERROR,
873 "internal error",
874 "err_internal",
875 )
876 })??;
877 }
878
879 if target.exists() && !overwrite {
880 // Drain this part and report it as a conflict at the end.
881 while let Some(_chunk) = field.chunk().await.map_err(|_| {
882 ApiError::localized(
883 StatusCode::BAD_REQUEST,
884 "invalid upload data",
885 "err_bad_upload",
886 )
887 })? {}
888 skipped.push(part_name);
889 continue;
890 }
891 if target.exists() {
892 if target.is_dir() {
893 return Err(ApiError::localized(
894 StatusCode::CONFLICT,
895 "a folder with this name already exists",
896 "err_folder_exists",
897 ));
898 }
899 tokio::fs::remove_file(&target).await.map_err(|_| {
900 ApiError::localized(
901 StatusCode::INTERNAL_SERVER_ERROR,
902 "internal error",
903 "err_internal",
904 )
905 })?;
906 }
907
908 // Stream to a temp file in the same directory, then rename into place.
909 let suffix = crate::auth::random_token();
910 let tmp = parent.join(format!(".upload-{suffix}"));
911 let mut tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
912 ApiError::localized(
913 StatusCode::INTERNAL_SERVER_ERROR,
914 "internal error",
915 "err_internal",
916 )
917 })?;
918 let write_failed = loop {
919 match field.chunk().await.map_err(|_| {
920 ApiError::localized(
921 StatusCode::BAD_REQUEST,
922 "invalid upload data",
923 "err_bad_upload",
924 )
925 }) {
926 Ok(Some(chunk)) => {
927 if let Err(e) = tmp_file.write_all(&chunk).await {
928 tracing::warn!(error = %e, "write failed during upload");
929 break true;
930 }
931 }
932 Ok(None) => break false,
933 Err(e) => return Err(e),
934 }
935 };
936 if write_failed {
937 let _ = tokio::fs::remove_file(&tmp).await;
938 return Err(ApiError::localized(
939 StatusCode::INTERNAL_SERVER_ERROR,
940 "could not save the file",
941 "err_save_failed",
942 ));
943 }
944 let tmp2 = tmp.clone();
945 let target2 = target.clone();
946 let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
947 .await
948 .map_err(|_| {
949 ApiError::localized(
950 StatusCode::INTERNAL_SERVER_ERROR,
951 "internal error",
952 "err_internal",
953 )
954 })?;
955 renamed.map_err(|e| {
956 let _ = std::fs::remove_file(&tmp);
957 tracing::warn!(error = %e, "rename failed during upload");
958 ApiError::localized(
959 StatusCode::INTERNAL_SERVER_ERROR,
960 "internal error",
961 "err_internal",
962 )
963 })?;
964 uploaded += 1;
965 }
966
967 if uploaded == 0 && skipped.is_empty() {
968 return Err(ApiError::localized(
969 StatusCode::BAD_REQUEST,
970 "no files were uploaded",
971 "err_no_files_uploaded",
972 ));
973 }
974 if !skipped.is_empty() {
975 return Err(ApiError::localized(
976 StatusCode::CONFLICT,
977 "some files already exist",
978 "err_files_exist",
979 )
980 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
981 }
982 Ok(Json(UploadResp { ok: true, uploaded }).into_response())
983}
984
985fn parse_boundary(content_type: &str) -> Option<String> {
986 content_type
987 .split(';')
988 .map(|s| s.trim())
989 .find_map(|s| s.strip_prefix("boundary="))
990 .map(|b| b.trim_matches('"').to_string())
991 .filter(|b| !b.is_empty())
992}
993
994/// Read one query parameter from the request URI.
995fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
996 uri.query()?.split('&').find_map(|kv| {
997 let (k, v) = kv.split_once('=')?;
998 (k == key).then(|| v.to_string())
999 })
1000}
1001
1002fn action_param(uri: &axum::http::Uri) -> Option<String> {
1003 query_param(uri, P_ACTION)
1004}
1005
1006fn parse_overwrite(uri: &axum::http::Uri) -> bool {
1007 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
1008}
1009
1010fn validate_rel_path(name: &str) -> Result<(), ApiError> {
1011 for c in std::path::Path::new(name).components() {
1012 match c {
1013 Component::Normal(_) => {}
1014 _ => {
1015 return Err(ApiError::localized(
1016 StatusCode::BAD_REQUEST,
1017 "invalid file path in upload",
1018 "err_bad_upload_path",
1019 ));
1020 }
1021 }
1022 }
1023 Ok(())
1024}
1025
1026// ---------------------------------------------------------------------------
1027// Helpers
1028// ---------------------------------------------------------------------------
1029
1030fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1031 roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
1032 ApiError::localized(
1033 StatusCode::FORBIDDEN,
1034 "no such folder",
1035 "err_no_such_folder",
1036 )
1037 })
1038}
1039
1040fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1041 let root = find_root(roots, root_id)?;
1042 if !root.mode.is_writable() {
1043 return Err(ApiError::localized(
1044 StatusCode::FORBIDDEN,
1045 "read-only folder",
1046 "err_read_only_folder",
1047 ));
1048 }
1049 Ok(root)
1050}
1051