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