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