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