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