files.rs
⎇
Raw
1//! File operations API.
2//!
3//! All operations live on the same URL shape as the listing, distinguished by
4//! method (and content type for POST):
5//!
6//! - `GET /api/files/{root_id}` and `/api/files/{root_id}/{*path}` — list
7//! - `DELETE /api/files/{root_id}/{*path}` — delete a file or folder
8//! - `POST /api/files/{root_id}/{*path}` — create a folder (no body)
9//! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy
10//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
11
12use std::io::{self, Read};
13use std::path::Component;
14use std::sync::Arc;
15
16use axum::Json;
17use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
18use axum::http::{StatusCode, header};
19use axum::response::{IntoResponse, Response};
20use futures_util::StreamExt;
21use multer::Multipart;
22use serde::Deserialize;
23use tokio::io::AsyncWriteExt;
24use tokio::sync::mpsc;
25use tokio_stream::wrappers::ReceiverStream;
26
27use crate::api::common::AuthUser;
28use crate::archive::{self, ArchiveFormat};
29use crate::db::{RootRow, ShareRow};
30use crate::error::{ApiError, AppState};
31use crate::fs::{self, FsError};
32use api_types::{FilesResp, Mutation, OkResp, Op, P_ACTION, P_OVERWRITE, SaveResp, UploadResp};
33
34/// Upper bound for the in-memory text endpoint (preview, later editor).
35const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
36
37// ---------------------------------------------------------------------------
38// Query params
39// ---------------------------------------------------------------------------
40
41/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
42/// route lists the directory; `?action=download|preview|content` serve the
43/// item itself.
44#[derive(Deserialize, Default)]
45pub struct FileQuery {
46 #[serde(default)]
47 action: Option<String>,
48 #[serde(default)]
49 format: Option<String>,
50}
51
52// ---------------------------------------------------------------------------
53// Listing
54// ---------------------------------------------------------------------------
55
56/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
57/// itself via `?action=download|preview|content`.
58pub async fn file_get(
59 State(state): State<Arc<AppState>>,
60 auth: AuthUser,
61 path: AxumPath<(i64, String)>,
62 query: AxumQuery<FileQuery>,
63) -> Result<Response, ApiError> {
64 let (root_id, req_rel) = path.0;
65 match query.action.as_deref() {
66 Some(a) if a == api_types::ACTION_DOWNLOAD => {
67 download(state, auth, root_id, req_rel, query.format.as_deref()).await
68 }
69 Some(a) if a == api_types::ACTION_PREVIEW => preview(state, auth, root_id, req_rel).await,
70 Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
71 _ => {
72 let json = list_inner(state, auth, root_id, req_rel).await?;
73 Ok(json.into_response())
74 }
75 }
76}
77
78/// GET /api/files/{root_id} — list the root directory itself, or serve the
79/// root item via `?action=download|preview|content` (the root of a *file*
80/// share is the file itself).
81pub async fn list_root(
82 State(state): State<Arc<AppState>>,
83 auth: AuthUser,
84 path: AxumPath<i64>,
85 query: AxumQuery<FileQuery>,
86) -> Result<Response, ApiError> {
87 let root_id = path.0;
88 match query.action.as_deref() {
89 Some(a) if a == api_types::ACTION_DOWNLOAD => {
90 download(state, auth, root_id, String::new(), query.format.as_deref()).await
91 }
92 Some(a) if a == api_types::ACTION_PREVIEW => {
93 preview(state, auth, root_id, String::new()).await
94 }
95 Some(a) if a == api_types::ACTION_CONTENT => {
96 content(state, auth, root_id, String::new()).await
97 }
98 _ => {
99 let json = list_inner(state, auth, root_id, String::new()).await?;
100 Ok(json.into_response())
101 }
102 }
103}
104
105async fn list_inner(
106 state: Arc<AppState>,
107 auth: AuthUser,
108 root_id: i64,
109 req_rel: String,
110) -> Result<Json<FilesResp>, ApiError> {
111 let root = find_root(&auth.roots, root_id)?;
112 let server_root = state.root.clone();
113 let root_rel = root.path.clone();
114 let full =
115 tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
116 .await
117 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
118
119 let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
120 .await
121 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
122
123 Ok(Json(FilesResp { entries }))
124}
125
126// ---------------------------------------------------------------------------
127// download / preview / content (milestone 4)
128// ---------------------------------------------------------------------------
129
130/// Escape a file name for a `Content-Disposition` header value.
131fn disp_name(name: &str) -> String {
132 name.replace('\\', "\\\\")
133 .replace('"', "\\\"")
134 .replace(['\n', '\r'], "_")
135}
136
137/// Resolve the requested item to an absolute path + metadata (blocking).
138async fn resolve_item(
139 state: &AppState,
140 root: &RootRow,
141 req_rel: &str,
142 share: Option<&ShareRow>,
143) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
144 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
145 // A file share's synthetic root *is* the file, so resolve it directly.
146 let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
147 let inner = tokio::task::spawn_blocking(move || {
148 let full = match share_target {
149 Some(target) => fs::resolve_file(&server_root, &target)?,
150 None => fs::resolve_path(&server_root, &root_rel, &rel)?,
151 };
152 let name = full
153 .file_name()
154 .map(|n| n.to_string_lossy().into_owned())
155 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
156 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
157 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
158 })
159 .await
160 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
161 Ok(inner?)
162}
163
164/// `GET ...?action=download` — a single file as-is, a folder as an archive
165/// (format chosen by the client).
166async fn download(
167 state: Arc<AppState>,
168 auth: AuthUser,
169 root_id: i64,
170 req_rel: String,
171 format: Option<&str>,
172) -> Result<Response, ApiError> {
173 let root = find_root(&auth.roots, root_id)?;
174 let (full, name, is_dir, size) =
175 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
176
177 if !is_dir {
178 return file_response(&full, &name, size, false).await;
179 }
180
181 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
182 ApiError::new(
183 StatusCode::BAD_REQUEST,
184 "format must be one of: zip, tar, tar.gz, tar.zst",
185 )
186 })?;
187 let disp = format!(
188 "attachment; filename=\"{}.{}\"",
189 disp_name(&name),
190 fmt.extension()
191 );
192 let body = stream_archive(fmt, full, name);
193 Response::builder()
194 .status(StatusCode::OK)
195 .header(header::CONTENT_TYPE, fmt.mime())
196 .header(header::CONTENT_DISPOSITION, disp)
197 .body(body)
198 .map_err(|e| {
199 ApiError::new(
200 StatusCode::INTERNAL_SERVER_ERROR,
201 format!("bad response: {e}"),
202 )
203 })
204}
205
206/// `GET ...?action=preview` — a single file, inline (for native media).
207async fn preview(
208 state: Arc<AppState>,
209 auth: AuthUser,
210 root_id: i64,
211 req_rel: String,
212) -> Result<Response, ApiError> {
213 let root = find_root(&auth.roots, root_id)?;
214 let (full, name, is_dir, size) =
215 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
216 if is_dir {
217 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
218 }
219 file_response(&full, &name, size, true).await
220}
221
222/// `GET ...?action=content` — raw file bytes for the text preview/editor.
223/// Capped at `MAX_TEXT_BYTES`.
224async fn content(
225 state: Arc<AppState>,
226 auth: AuthUser,
227 root_id: i64,
228 req_rel: String,
229) -> Result<Response, ApiError> {
230 let root = find_root(&auth.roots, root_id)?;
231 let (full, _name, is_dir, size) =
232 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
233 if is_dir {
234 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
235 }
236 if size > MAX_TEXT_BYTES {
237 return Err(ApiError::new(
238 StatusCode::PAYLOAD_TOO_LARGE,
239 "file too large to preview",
240 ));
241 }
242 let bytes = tokio::fs::read(&full)
243 .await
244 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
245 let meta = tokio::fs::metadata(&full)
246 .await
247 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
248 let mtime = fs::mtime_secs(&meta).unwrap_or(0);
249 Ok((
250 [
251 (
252 header::CONTENT_TYPE,
253 "text/plain; charset=utf-8".to_string(),
254 ),
255 (
256 axum::http::HeaderName::from_static("x-file-mtime"),
257 mtime.to_string(),
258 ),
259 ],
260 bytes,
261 )
262 .into_response())
263}
264
265/// `PUT ...?action=content` — save a file's text contents (the editor).
266///
267/// Requires a read-write root. The body is the new contents. If the
268/// `X-Expected-Mtime` header is present, the file's current mtime must match
269/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
270/// Returns the file's new mtime so the client can anchor the next check.
271pub async fn file_put(
272 State(state): State<Arc<AppState>>,
273 auth: AuthUser,
274 path: AxumPath<(i64, String)>,
275 query: AxumQuery<FileQuery>,
276 headers: axum::http::HeaderMap,
277 body: axum::body::Bytes,
278) -> Result<Json<SaveResp>, ApiError> {
279 let (root_id, req_rel) = path.0;
280 put_inner(state, auth, root_id, req_rel, query, headers, body).await
281}
282
283/// `PUT .../{root_id}?action=content` — the root item itself. Only reachable
284/// for a *file* share (its root is the file); for folders it resolves to a
285/// directory and is rejected below.
286pub async fn file_put_root(
287 State(state): State<Arc<AppState>>,
288 auth: AuthUser,
289 path: AxumPath<i64>,
290 query: AxumQuery<FileQuery>,
291 headers: axum::http::HeaderMap,
292 body: axum::body::Bytes,
293) -> Result<Json<SaveResp>, ApiError> {
294 put_inner(state, auth, path.0, String::new(), query, headers, body).await
295}
296
297async fn put_inner(
298 state: Arc<AppState>,
299 auth: AuthUser,
300 root_id: i64,
301 req_rel: String,
302 query: AxumQuery<FileQuery>,
303 headers: axum::http::HeaderMap,
304 body: axum::body::Bytes,
305) -> Result<Json<SaveResp>, ApiError> {
306 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
307 return Err(ApiError::new(
308 StatusCode::BAD_REQUEST,
309 "expected action=content",
310 ));
311 }
312 let root = require_rw_root(&auth.roots, root_id)?;
313 if body.len() as u64 > MAX_TEXT_BYTES {
314 return Err(ApiError::new(
315 StatusCode::PAYLOAD_TOO_LARGE,
316 "file too large to save",
317 ));
318 }
319 let expected: Option<i64> = headers
320 .get("x-expected-mtime")
321 .and_then(|v| v.to_str().ok())
322 .and_then(|s| s.parse().ok());
323 // A file share's synthetic root *is* the file, so resolve it directly.
324 let share_target = auth
325 .share
326 .as_ref()
327 .filter(|s| s.is_file)
328 .map(|s| s.target.clone());
329 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
330 let content = body.to_vec();
331 let mtime = tokio::task::spawn_blocking(move || match share_target {
332 Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
333 None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
334 })
335 .await
336 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
337 Ok(Json(SaveResp { ok: true, mtime }))
338}
339
340/// Stream a single file to the client with the right disposition.
341async fn file_response(
342 full: &std::path::Path,
343 name: &str,
344 size: u64,
345 inline: bool,
346) -> Result<Response, ApiError> {
347 let mime = mime_guess::from_path(full)
348 .first_or_octet_stream()
349 .to_string();
350 let disp = if inline {
351 format!("inline; filename=\"{}\"", disp_name(name))
352 } else {
353 format!("attachment; filename=\"{}\"", disp_name(name))
354 };
355 let body = stream_file(full.to_path_buf());
356 let mut res = Response::builder()
357 .status(StatusCode::OK)
358 .header(header::CONTENT_DISPOSITION, disp)
359 .header(header::CONTENT_LENGTH, size);
360 // A file the browser would parse as a document (HTML/SVG/XML) is served
361 // under the sandboxed policy, so it can render as a page without being
362 // able to act as the app. Derived from the same `mime` we declare.
363 // Non-scriptable inline files (PDF, …) are frameable by the app itself,
364 // for the preview modal.
365 if crate::api::is_scriptable_mime(&mime) {
366 res = res.header("content-security-policy", crate::api::FILE_CSP);
367 } else if inline {
368 res = res
369 .header("content-security-policy", crate::api::INLINE_CSP)
370 .header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
371 }
372 res.header(header::CONTENT_TYPE, mime)
373 .body(body)
374 .map_err(|e| {
375 ApiError::new(
376 StatusCode::INTERNAL_SERVER_ERROR,
377 format!("bad response: {e}"),
378 )
379 })
380}
381
382/// Stream `path` to the client in chunks (blocking reader → channel).
383fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
384 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
385 tokio::task::spawn_blocking(move || {
386 let mut f = match std::fs::File::open(&path) {
387 Ok(f) => f,
388 Err(e) => {
389 tracing::warn!(error = %e, path = %path.display(), "download open failed");
390 return;
391 }
392 };
393 let mut buf = vec![0u8; 256 * 1024];
394 loop {
395 match f.read(&mut buf) {
396 Ok(0) => break,
397 Ok(n) => {
398 // Client gone → stop producing.
399 if tx.blocking_send(buf[..n].to_vec()).is_err() {
400 break;
401 }
402 }
403 Err(e) => {
404 tracing::warn!(error = %e, path = %path.display(), "download read failed");
405 break;
406 }
407 }
408 }
409 });
410 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
411 axum::body::Body::from_stream(stream)
412}
413
414/// Stream an archive of `dir` (top-level entry `top`) to the client.
415fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
416 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
417 tokio::task::spawn_blocking(move || {
418 let mut sink = ChanWriter::new(tx);
419 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
420 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
421 }
422 // Dropping the sink flushes its buffer and closes the channel.
423 });
424 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
425 axum::body::Body::from_stream(stream)
426}
427
428/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
429/// bridge between the blocking archive builder and the async response body.
430struct ChanWriter {
431 tx: mpsc::Sender<Vec<u8>>,
432 buf: Vec<u8>,
433}
434
435impl ChanWriter {
436 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
437 Self {
438 tx,
439 buf: Vec::with_capacity(64 * 1024),
440 }
441 }
442}
443
444impl io::Write for ChanWriter {
445 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
446 self.buf.extend_from_slice(b);
447 if self.buf.len() >= 64 * 1024 {
448 io::Write::flush(self)?;
449 }
450 Ok(b.len())
451 }
452 fn flush(&mut self) -> io::Result<()> {
453 if !self.buf.is_empty() {
454 let chunk = std::mem::take(&mut self.buf);
455 self.tx
456 .blocking_send(chunk)
457 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
458 }
459 Ok(())
460 }
461}
462
463impl Drop for ChanWriter {
464 fn drop(&mut self) {
465 let _ = io::Write::flush(self);
466 }
467}
468
469// ---------------------------------------------------------------------------
470// POST dispatch: mkdir | rename/move/copy | upload
471// ---------------------------------------------------------------------------
472
473/// POST /api/files/{root_id} — top-level operations (upload into the root
474/// directory). The bare root never targets a specific item, so JSON ops with
475/// a missing folder name are rejected by the individual handlers.
476pub async fn dispatch_root(
477 State(state): State<Arc<AppState>>,
478 auth: AuthUser,
479 path: AxumPath<i64>,
480 headers: axum::http::HeaderMap,
481 req: axum::http::Request<axum::body::Body>,
482) -> Result<Response, ApiError> {
483 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
484}
485
486/// POST /api/files/{root_id}/{*path}
487pub async fn dispatch(
488 State(state): State<Arc<AppState>>,
489 auth: AuthUser,
490 path: AxumPath<(i64, String)>,
491 headers: axum::http::HeaderMap,
492 req: axum::http::Request<axum::body::Body>,
493) -> Result<Response, ApiError> {
494 let (root_id, req_rel) = path.0;
495 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
496}
497
498/// Route one POST to upload, mutation or mkdir.
499///
500/// Upload and mutation are recognized by their content type. mkdir carries no
501/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
502/// an unrecognized content type used to fall through to mkdir, which turned a
503/// typo in a header into a silently created folder.
504async fn dispatch_inner(
505 state: Arc<AppState>,
506 auth: AuthUser,
507 root_id: i64,
508 req_rel: String,
509 headers: axum::http::HeaderMap,
510 req: axum::http::Request<axum::body::Body>,
511) -> Result<Response, ApiError> {
512 let ct = headers
513 .get(header::CONTENT_TYPE)
514 .and_then(|v| v.to_str().ok())
515 .unwrap_or("");
516
517 if ct.starts_with("multipart/form-data") {
518 return upload(state, auth, root_id, req_rel, req).await;
519 }
520 if ct.starts_with("application/json") {
521 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
522 .await
523 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
524 let body: Mutation = axum::Json::from_bytes(&bytes)
525 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
526 .0;
527 return Ok(mutation(state, auth, root_id, req_rel, body)
528 .await?
529 .into_response());
530 }
531 if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
532 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
533 }
534 Err(ApiError::new(
535 StatusCode::UNSUPPORTED_MEDIA_TYPE,
536 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
537 ))
538}
539
540// ---------------------------------------------------------------------------
541// mkdir
542// ---------------------------------------------------------------------------
543
544async fn mkdir(
545 state: Arc<AppState>,
546 auth: AuthUser,
547 root_id: i64,
548 req_rel: String,
549) -> Result<Json<OkResp>, ApiError> {
550 let root = require_rw_root(&auth.roots, root_id)?;
551 if req_rel.trim().is_empty() {
552 return Err(ApiError::new(
553 StatusCode::BAD_REQUEST,
554 "a folder name is required",
555 ));
556 }
557 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
558 tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
559 .await
560 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
561 Ok(Json(OkResp { ok: true }))
562}
563
564// ---------------------------------------------------------------------------
565// rename / move / copy
566// ---------------------------------------------------------------------------
567
568async fn mutation(
569 state: Arc<AppState>,
570 auth: AuthUser,
571 root_id: i64,
572 req_rel: String,
573 body: Mutation,
574) -> Result<Json<OkResp>, ApiError> {
575 match body.op {
576 Op::Rename => {
577 let new_name = body
578 .new_name
579 .as_deref()
580 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
581 .to_string();
582 let root = require_rw_root(&auth.roots, root_id)?;
583 let (server_root, root_rel, rel, overwrite) = (
584 state.root.clone(),
585 root.path.clone(),
586 req_rel,
587 body.overwrite,
588 );
589 tokio::task::spawn_blocking(move || {
590 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
591 })
592 .await
593 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
594 Ok(Json(OkResp { ok: true }))
595 }
596 Op::Move | Op::Copy => {
597 let dst_root_id = body
598 .dst_root_id
599 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
600 let dst = body.dst.clone().unwrap_or_default();
601 // Moving or copying out of a folder requires rw there; copying
602 // *from* a read-only root is fine.
603 let op_is_move = body.op == Op::Move;
604 let src_root = if op_is_move {
605 require_rw_root(&auth.roots, root_id)?
606 } else {
607 find_root(&auth.roots, root_id)?
608 };
609 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
610 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
611 state.root.clone(),
612 src_root.path.clone(),
613 dst_root.path.clone(),
614 dst,
615 req_rel,
616 body.overwrite,
617 );
618 tokio::task::spawn_blocking(move || {
619 if op_is_move {
620 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
621 } else {
622 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
623 }
624 })
625 .await
626 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
627 Ok(Json(OkResp { ok: true }))
628 }
629 }
630}
631
632// ---------------------------------------------------------------------------
633// DELETE
634// ---------------------------------------------------------------------------
635
636pub async fn delete(
637 State(state): State<Arc<AppState>>,
638 auth: AuthUser,
639 path: AxumPath<(i64, String)>,
640) -> Result<Json<serde_json::Value>, ApiError> {
641 let (root_id, req_rel) = path.0;
642 let root = require_rw_root(&auth.roots, root_id)?;
643 if req_rel.trim().is_empty() {
644 return Err(ApiError::new(
645 StatusCode::BAD_REQUEST,
646 "a path inside the folder is required",
647 ));
648 }
649 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
650 let is_dir =
651 tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
652 .await
653 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
654 Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
655}
656
657// ---------------------------------------------------------------------------
658// Upload (multipart)
659// ---------------------------------------------------------------------------
660
661async fn upload(
662 state: Arc<AppState>,
663 auth: AuthUser,
664 root_id: i64,
665 req_rel: String,
666 req: axum::http::Request<axum::body::Body>,
667) -> Result<Response, ApiError> {
668 let root = require_rw_root(&auth.roots, root_id)?;
669 let base = {
670 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
671 tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
672 .await
673 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
674 };
675 let boundary = req
676 .headers()
677 .get(header::CONTENT_TYPE)
678 .and_then(|v| v.to_str().ok())
679 .and_then(parse_boundary)
680 .ok_or_else(|| {
681 ApiError::new(
682 StatusCode::BAD_REQUEST,
683 "expected multipart/form-data with a boundary",
684 )
685 })?;
686 let overwrite = parse_overwrite(req.uri());
687
688 let stream = req.into_body().into_data_stream();
689 let mut multipart = Multipart::new(stream, boundary);
690 let mut uploaded: usize = 0;
691 let mut skipped: Vec<String> = Vec::new();
692
693 while let Some(mut field) = multipart
694 .next_field()
695 .await
696 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
697 {
698 let part_name = field
699 .name()
700 .filter(|n| !n.is_empty())
701 .or_else(|| field.file_name())
702 .map(str::to_string)
703 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
704
705 validate_rel_path(&part_name)?;
706
707 let target = base.join(&part_name);
708 let parent = target
709 .parent()
710 .filter(|p| !p.as_os_str().is_empty())
711 .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
712 if !parent.is_dir() {
713 let p = parent.to_path_buf();
714 tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
715 .await
716 .map_err(|_| {
717 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
718 })??;
719 }
720
721 if target.exists() && !overwrite {
722 // Drain this part and report it as a conflict at the end.
723 while let Some(_chunk) = field
724 .chunk()
725 .await
726 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
727 {
728 }
729 skipped.push(part_name);
730 continue;
731 }
732 if target.exists() {
733 if target.is_dir() {
734 return Err(ApiError::new(
735 StatusCode::CONFLICT,
736 "a folder with this name already exists",
737 ));
738 }
739 tokio::fs::remove_file(&target)
740 .await
741 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
742 }
743
744 // Stream to a temp file in the same directory, then rename into place.
745 let suffix = crate::auth::random_token();
746 let tmp = parent.join(format!(".upload-{suffix}"));
747 let mut tmp_file = tokio::fs::File::create(&tmp)
748 .await
749 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
750 let write_failed = loop {
751 match field
752 .chunk()
753 .await
754 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
755 {
756 Ok(Some(chunk)) => {
757 if let Err(e) = tmp_file.write_all(&chunk).await {
758 tracing::warn!(error = %e, "write failed during upload");
759 break true;
760 }
761 }
762 Ok(None) => break false,
763 Err(e) => return Err(e),
764 }
765 };
766 if write_failed {
767 let _ = tokio::fs::remove_file(&tmp).await;
768 return Err(ApiError::new(
769 StatusCode::INTERNAL_SERVER_ERROR,
770 "could not save the file",
771 ));
772 }
773 let tmp2 = tmp.clone();
774 let target2 = target.clone();
775 let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
776 .await
777 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
778 renamed.map_err(|e| {
779 let _ = std::fs::remove_file(&tmp);
780 tracing::warn!(error = %e, "rename failed during upload");
781 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
782 })?;
783 uploaded += 1;
784 }
785
786 if uploaded == 0 && skipped.is_empty() {
787 return Err(ApiError::new(
788 StatusCode::BAD_REQUEST,
789 "no files were uploaded",
790 ));
791 }
792 if !skipped.is_empty() {
793 return Err(
794 ApiError::new(StatusCode::CONFLICT, "some files already exist")
795 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
796 );
797 }
798 Ok(Json(UploadResp { ok: true, uploaded }).into_response())
799}
800
801fn parse_boundary(content_type: &str) -> Option<String> {
802 content_type
803 .split(';')
804 .map(|s| s.trim())
805 .find_map(|s| s.strip_prefix("boundary="))
806 .map(|b| b.trim_matches('"').to_string())
807 .filter(|b| !b.is_empty())
808}
809
810/// Read one query parameter from the request URI.
811fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
812 uri.query()?.split('&').find_map(|kv| {
813 let (k, v) = kv.split_once('=')?;
814 (k == key).then(|| v.to_string())
815 })
816}
817
818fn action_param(uri: &axum::http::Uri) -> Option<String> {
819 query_param(uri, P_ACTION)
820}
821
822fn parse_overwrite(uri: &axum::http::Uri) -> bool {
823 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
824}
825
826fn validate_rel_path(name: &str) -> Result<(), ApiError> {
827 for c in std::path::Path::new(name).components() {
828 match c {
829 Component::Normal(_) => {}
830 _ => {
831 return Err(ApiError::new(
832 StatusCode::BAD_REQUEST,
833 "invalid file path in upload",
834 ));
835 }
836 }
837 }
838 Ok(())
839}
840
841// ---------------------------------------------------------------------------
842// Helpers
843// ---------------------------------------------------------------------------
844
845fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
846 roots
847 .iter()
848 .find(|r| r.id == root_id)
849 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
850}
851
852fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
853 let root = find_root(roots, root_id)?;
854 if !root.mode.is_writable() {
855 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
856 }
857 Ok(root)
858}
859