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