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//! or `?action=exists` — which upload targets already exist
11//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
12
13use std::io::{self, Read};
14use std::path::{Component, Path, PathBuf};
15use std::sync::Arc;
16
17use axum::Json;
18use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
19use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
20use axum::response::{IntoResponse, Response};
21use futures_util::StreamExt;
22use multer::Multipart;
23use serde::Deserialize;
24use tokio::io::AsyncWriteExt;
25use tokio::sync::mpsc;
26use tower_http::services::ServeFile;
27
28use crate::api::common::{AuthUser, blocking, target_rel};
29use crate::archive::{self, ArchiveFormat};
30use crate::db::{RootRow, ShareRow};
31use crate::error::{ApiError, AppState};
32use crate::fs::{self, FsError};
33use api_types::{
34 Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, SaveResp, SortKey,
35};
36
37/// Upper bound for the in-memory text endpoint (preview, later editor).
38pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
39
40// ---------------------------------------------------------------------------
41// Query params
42// ---------------------------------------------------------------------------
43
44/// Query params for every file route. Without `action` a `GET` lists the
45/// directory, and the listing params pick the page;
46/// `?action=download|preview|content` serve the item itself.
47///
48/// The field names are the shared `P_*` constants.
49/// `#[serde(rename)]` only takes a literal, so that link cannot be written
50/// here; `tests::query_fields_are_the_shared_constants` pins it instead.
51#[derive(Deserialize)]
52pub struct FileQuery {
53 action: Option<String>,
54 format: Option<String>,
55 /// Upload: replace existing files. `true` or `1`.
56 overwrite: Option<String>,
57 #[serde(default)]
58 sort: SortKey,
59 #[serde(default)]
60 desc: bool,
61 #[serde(default)]
62 offset: usize,
63 limit: Option<usize>,
64 #[serde(default)]
65 dirs: bool,
66 around: Option<String>,
67}
68
69impl FileQuery {
70 fn overwrite(&self) -> bool {
71 matches!(self.overwrite.as_deref(), Some("true" | "1"))
72 }
73}
74
75/// Path params of both file routes. The bare `{root_id}` route has no path:
76/// it names the root item itself, which for a *file* share is the file.
77#[derive(Deserialize)]
78pub struct Loc {
79 root_id: i64,
80 #[serde(default)]
81 path: String,
82}
83
84// ---------------------------------------------------------------------------
85// Listing
86// ---------------------------------------------------------------------------
87
88/// GET — list a directory, or serve the item itself via
89/// `?action=download|preview|content|thumb`.
90pub async fn file_get(
91 State(state): State<Arc<AppState>>,
92 auth: AuthUser,
93 AxumPath(Loc {
94 root_id,
95 path: req_rel,
96 }): AxumPath<Loc>,
97 AxumQuery(query): AxumQuery<FileQuery>,
98 headers: HeaderMap,
99) -> Result<Response, ApiError> {
100 match query.action.as_deref() {
101 Some(api_types::ACTION_DOWNLOAD) => {
102 download(
103 state,
104 auth,
105 root_id,
106 req_rel,
107 query.format.as_deref(),
108 &headers,
109 )
110 .await
111 }
112 Some(api_types::ACTION_PREVIEW) => preview(state, auth, root_id, req_rel, &headers).await,
113 Some(api_types::ACTION_CONTENT) => content(state, auth, root_id, req_rel).await,
114 Some(api_types::ACTION_THUMB) => thumb(state, auth, root_id, req_rel).await,
115 _ => {
116 let opts = fs::ListOpts {
117 sort: query.sort,
118 desc: query.desc,
119 offset: query.offset,
120 limit: query.limit,
121 dirs_only: query.dirs,
122 around: query.around,
123 };
124 let json = list_inner(state, auth, root_id, req_rel, opts).await?;
125 Ok(json.into_response())
126 }
127 }
128}
129
130/// Caching policy for a served file.
131///
132/// `private` because the response depends on who asked. `no-cache`, not
133/// `no-store`, so a client can revalidate a large media file and get a `304`
134/// instead of re-downloading it.
135const FILE_CACHE: &str = "private, no-cache";
136
137/// Whether an mtime (unix seconds) may be a `Last-Modified` validator. Not an
138/// unknown mtime (0), and not one from the last two seconds: whole-second
139/// dates cannot tell two writes in one second apart.
140fn trusted_mtime(mtime: i64) -> bool {
141 mtime > 0 && mtime + 2 <= chrono::Utc::now().timestamp()
142}
143
144async fn list_inner(
145 state: Arc<AppState>,
146 auth: AuthUser,
147 root_id: i64,
148 req_rel: String,
149 opts: fs::ListOpts,
150) -> Result<Json<FilesResp>, ApiError> {
151 // A file share's root is the file itself: there is nothing to list.
152 if auth.share.as_ref().is_some_and(|s| s.is_file) {
153 return Err(FsError::NotADirectory.into());
154 }
155 let root = find_root(&auth.roots, root_id)?;
156 let server_root = state.root.clone();
157 let root_rel = root.path.clone();
158 // Resolve and list in one blocking hop: both are filesystem work.
159 let resp = blocking(move || {
160 let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?;
161 fs::list_dir(&full, opts)
162 })
163 .await?;
164
165 Ok(Json(resp))
166}
167
168// ---------------------------------------------------------------------------
169// download / preview / content (milestone 4)
170// ---------------------------------------------------------------------------
171
172/// `Content-Disposition` parameters for `name`: an ASCII `filename=` fallback
173/// (non-ASCII and control bytes become `_`) plus the RFC 8187 `filename*=`
174/// that every current browser reads. Never fails header validation.
175pub(crate) fn disposition(kind: &str, name: &str) -> String {
176 let ascii: String = name
177 .chars()
178 .map(|c| match c {
179 '"' | '\\' => '_',
180 c if c.is_ascii_graphic() || c == ' ' => c,
181 _ => '_',
182 })
183 .collect();
184 let mut enc = String::with_capacity(name.len() * 3);
185 for b in name.bytes() {
186 // attr-char per RFC 8187.
187 if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
188 enc.push(b as char);
189 } else {
190 use std::fmt::Write as _;
191 let _ = write!(enc, "%{b:02X}");
192 }
193 }
194 format!("{kind}; filename=\"{ascii}\"; filename*=UTF-8''{enc}")
195}
196
197/// Resolve the requested item to an absolute path + metadata (blocking).
198async fn resolve_item(
199 state: &AppState,
200 root: &RootRow,
201 req_rel: &str,
202 share: Option<&ShareRow>,
203) -> Result<(std::path::PathBuf, String, bool, u64, i64), ApiError> {
204 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
205 // A file share's synthetic root *is* the file, so resolve it directly.
206 let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
207 blocking(move || {
208 let full = match share_target {
209 Some(target) => fs::resolve_file(&server_root, &target)?,
210 None => fs::resolve_path(&server_root, &root_rel, &rel)?,
211 };
212 let name = full
213 .file_name()
214 .map(|n| n.to_string_lossy().into_owned())
215 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
216 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
217 let mtime = fs::mtime_secs(&meta).unwrap_or(0);
218 Ok::<_, FsError>((full, name, meta.is_dir(), meta.len(), mtime))
219 })
220 .await
221}
222
223/// `GET ...?action=download` — a single file as-is, a folder as an archive
224/// (format chosen by the client).
225async fn download(
226 state: Arc<AppState>,
227 auth: AuthUser,
228 root_id: i64,
229 req_rel: String,
230 format: Option<&str>,
231 headers: &HeaderMap,
232) -> Result<Response, ApiError> {
233 let root = find_root(&auth.roots, root_id)?;
234 let (full, name, is_dir, _size, mtime) =
235 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
236
237 if !is_dir {
238 return file_response(&full, &name, false, mtime, headers).await;
239 }
240
241 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
242 ApiError::localized(
243 StatusCode::BAD_REQUEST,
244 "format must be one of: zip, tar, tar.gz, tar.zst",
245 "err_bad_format",
246 )
247 })?;
248 let disp = disposition("attachment", &format!("{name}.{}", fmt.extension()));
249 let body = stream_archive(fmt, full, name);
250 Response::builder()
251 .status(StatusCode::OK)
252 .header(header::CONTENT_TYPE, fmt.mime())
253 .header(header::CONTENT_DISPOSITION, disp)
254 .header(header::CACHE_CONTROL, FILE_CACHE)
255 .body(body)
256 .map_err(|e| {
257 ApiError::new(
258 StatusCode::INTERNAL_SERVER_ERROR,
259 format!("bad response: {e}"),
260 )
261 })
262}
263
264/// `GET ...?action=preview` — a single file, inline (for native media).
265async fn preview(
266 state: Arc<AppState>,
267 auth: AuthUser,
268 root_id: i64,
269 req_rel: String,
270 headers: &HeaderMap,
271) -> Result<Response, ApiError> {
272 let root = find_root(&auth.roots, root_id)?;
273 let (full, name, is_dir, _size, mtime) =
274 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
275 if is_dir {
276 return Err(not_a_file());
277 }
278 file_response(&full, &name, true, mtime, headers).await
279}
280
281fn not_a_file() -> ApiError {
282 ApiError::localized(StatusCode::BAD_REQUEST, "not a file", "err_not_a_file")
283}
284
285/// `GET ...?action=content` — raw file bytes for the text preview/editor.
286/// Capped at `MAX_TEXT_BYTES`.
287async fn content(
288 state: Arc<AppState>,
289 auth: AuthUser,
290 root_id: i64,
291 req_rel: String,
292) -> Result<Response, ApiError> {
293 let root = find_root(&auth.roots, root_id)?;
294 let (full, _name, is_dir, size, _mtime) =
295 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
296 if is_dir {
297 return Err(not_a_file());
298 }
299 if size > MAX_TEXT_BYTES {
300 return Err(ApiError::localized(
301 StatusCode::PAYLOAD_TOO_LARGE,
302 "file too large to preview",
303 "err_too_large_preview",
304 ));
305 }
306 // mtime and bytes from one handle, mtime first: a write in between would
307 // otherwise hand the editor a stale conflict anchor for fresh content.
308 let (mtime, bytes) = blocking(move || -> io::Result<(i64, Vec<u8>)> {
309 let mut f = std::fs::File::open(&full)?;
310 let mtime = fs::mtime_secs(&f.metadata()?).unwrap_or(0);
311 let mut bytes = Vec::with_capacity(size as usize);
312 f.read_to_end(&mut bytes)?;
313 Ok((mtime, bytes))
314 })
315 .await?;
316 Ok((
317 [
318 (
319 header::CONTENT_TYPE,
320 "text/plain; charset=utf-8".to_string(),
321 ),
322 (header::CACHE_CONTROL, FILE_CACHE.to_string()),
323 (
324 axum::http::HeaderName::from_static("x-file-mtime"),
325 mtime.to_string(),
326 ),
327 ],
328 bytes,
329 )
330 .into_response())
331}
332
333/// `GET ...?action=thumb` — a small WebP preview of an image or video.
334///
335/// `404` covers every "no thumbnail here" case: thumbnails switched off, a
336/// folder, a kind we do not render, an undecodable file, a video without
337/// ffmpeg. The grid falls back to its icon for all of them alike.
338async fn thumb(
339 state: Arc<AppState>,
340 auth: AuthUser,
341 root_id: i64,
342 req_rel: String,
343) -> Result<Response, ApiError> {
344 let Some(thumbs) = state.thumbs.as_ref() else {
345 return Err(no_thumb());
346 };
347 let root = find_root(&auth.roots, root_id)?;
348 let (full, _name, is_dir, size, mtime) =
349 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
350 if is_dir {
351 return Err(no_thumb());
352 }
353 // The kind is sniffed from the bytes, not the name, so an mp4 called .txt
354 // still gets a thumbnail and a .jpg full of text does not.
355 let kind = {
356 let full = full.clone();
357 blocking(move || Ok::<_, FsError>(fs::detect_kind(&full, false))).await?
358 };
359 let Some(bytes) = thumbs.get(&full, kind, size, mtime).await else {
360 return Err(no_thumb());
361 };
362 Ok((
363 [
364 (header::CONTENT_TYPE, "image/webp"),
365 // Safe despite the stable path: the client varies the query on
366 // mtime, so new content always means a new URL.
367 (
368 header::CACHE_CONTROL,
369 "private, max-age=31536000, immutable",
370 ),
371 ],
372 bytes,
373 )
374 .into_response())
375}
376
377/// The message never reaches a user: an `<img>` 404 just leaves the tile's
378/// icon showing. That is why it carries no localized code.
379fn no_thumb() -> ApiError {
380 ApiError::new(StatusCode::NOT_FOUND, "no thumbnail".to_string())
381}
382
383/// `PUT ...?action=content` — save a file's text contents (the editor).
384///
385/// Requires a read-write root. The body is the new contents. If the
386/// `X-Expected-Mtime` header is present, the file's current mtime must match
387/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
388/// Returns the file's new mtime so the client can anchor the next check.
389///
390/// On the bare root only a *file* share gets past the resolve: for a folder
391/// the root is a directory, which is rejected.
392pub async fn file_put(
393 State(state): State<Arc<AppState>>,
394 auth: AuthUser,
395 AxumPath(Loc {
396 root_id,
397 path: req_rel,
398 }): AxumPath<Loc>,
399 AxumQuery(query): AxumQuery<FileQuery>,
400 headers: HeaderMap,
401 body: axum::body::Bytes,
402) -> Result<Json<SaveResp>, ApiError> {
403 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
404 return Err(ApiError::localized(
405 StatusCode::BAD_REQUEST,
406 "expected action=content",
407 "err_bad_action",
408 ));
409 }
410 let root = require_rw_root(&auth.roots, root_id)?;
411 if body.len() as u64 > MAX_TEXT_BYTES {
412 return Err(ApiError::localized(
413 StatusCode::PAYLOAD_TOO_LARGE,
414 "file too large to save",
415 "err_too_large_save",
416 ));
417 }
418 let expected: Option<i64> = headers
419 .get("x-expected-mtime")
420 .and_then(|v| v.to_str().ok())
421 .and_then(|s| s.parse().ok());
422 // A file share's synthetic root *is* the file, so resolve it directly.
423 let share_target = auth
424 .share
425 .as_ref()
426 .filter(|s| s.is_file)
427 .map(|s| s.target.clone());
428 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
429 let content = body.to_vec();
430 let mtime = blocking(move || {
431 let full = match share_target {
432 Some(target) => fs::resolve_file(&server_root, &target),
433 None => fs::resolve_path(&server_root, &root_rel, &rel),
434 }?;
435 fs::write_checked(&full, &content, expected)
436 })
437 .await?;
438 Ok(Json(SaveResp { mtime }))
439}
440
441/// Serve a single file with the right disposition. `ServeFile` answers
442/// `Range` (a 206 even for `bytes=0-`, which Firefox needs) and
443/// `If-Modified-Since`.
444async fn file_response(
445 full: &Path,
446 name: &str,
447 inline: bool,
448 mtime: i64,
449 headers: &HeaderMap,
450) -> Result<Response, ApiError> {
451 let mut req = axum::http::Request::new(());
452 if let Some(range) = headers.get(header::RANGE) {
453 req.headers_mut().insert(header::RANGE, range.clone());
454 }
455 if trusted_mtime(mtime)
456 && let Some(ims) = headers.get(header::IF_MODIFIED_SINCE)
457 {
458 req.headers_mut()
459 .insert(header::IF_MODIFIED_SINCE, ims.clone());
460 }
461 // `ServeFile` panics on an mtime it cannot write as an HTTP date: one
462 // before 1970, or in the year 9999 or later.
463 let meta = tokio::fs::metadata(full).await.ok();
464 let dateable = meta
465 .as_ref()
466 .and_then(|m| m.modified().ok())
467 .is_none_or(|t| {
468 t.duration_since(std::time::UNIX_EPOCH)
469 .is_ok_and(|d| d.as_secs() < YEAR_9999)
470 });
471 let mut res = match meta {
472 Some(m) if !dateable => whole_file(full.to_path_buf(), m.len()),
473 _ => ServeFile::new(full)
474 .try_call(req)
475 .await
476 .map_err(|e| {
477 tracing::warn!(error = %e, path = %full.display(), "download open failed");
478 ApiError::internal()
479 })?
480 .map(axum::body::Body::new),
481 };
482 // Judged again on the date actually served: the file may have been
483 // written since `mtime` was read.
484 let served = res
485 .headers()
486 .get(header::LAST_MODIFIED)
487 .and_then(|v| v.to_str().ok())
488 .and_then(|v| chrono::DateTime::parse_from_rfc2822(v).ok());
489 if !served.is_some_and(|t| trusted_mtime(t.timestamp())) {
490 res.headers_mut().remove(header::LAST_MODIFIED);
491 }
492 // Always sent, validator or not: with no directive a cache may apply
493 // heuristic freshness to a response that depends on who asked. A 304
494 // repeats it, so the stored copy does not lose the directive.
495 res.headers_mut()
496 .insert(header::CACHE_CONTROL, HeaderValue::from_static(FILE_CACHE));
497 // A file the browser would parse as a document (HTML/SVG/XML) is served
498 // under the sandboxed policy, so it can render as a page without being
499 // able to act as the app. Non-scriptable inline files (PDF, …) are
500 // frameable by the app itself, for the preview modal. A 304 gets the
501 // same policy: the browser copies its CSP onto the cached response, and
502 // the router would otherwise fill in the app policy.
503 let mime = mime_guess::from_path(full).first_or_octet_stream();
504 let h = res.headers_mut();
505 if crate::api::is_scriptable_mime(mime.essence_str()) {
506 h.insert(
507 "content-security-policy",
508 HeaderValue::from_static(crate::api::FILE_CSP),
509 );
510 } else if inline {
511 h.insert(
512 "content-security-policy",
513 HeaderValue::from_static(crate::api::INLINE_CSP),
514 );
515 h.insert(
516 header::X_FRAME_OPTIONS,
517 HeaderValue::from_static("SAMEORIGIN"),
518 );
519 }
520 if res.status().is_success() {
521 let disp = disposition(if inline { "inline" } else { "attachment" }, name);
522 res.headers_mut().insert(
523 header::CONTENT_DISPOSITION,
524 HeaderValue::try_from(disp).map_err(|_| ApiError::internal())?,
525 );
526 }
527 Ok(res)
528}
529
530/// 9999-01-01T00:00:00Z in unix seconds.
531const YEAR_9999: u64 = 253_402_300_800;
532
533/// The whole file as a plain 200, with no validator.
534// ponytail: no Range support, so a video in such a file cannot seek. Rare
535// enough (bogus archive timestamps); serve ranges here if it ever matters.
536fn whole_file(path: PathBuf, len: u64) -> Response {
537 let mime = mime_guess::from_path(&path).first_or_octet_stream();
538 let body = blocking_body(move |sink| {
539 if let Err(e) = std::fs::File::open(&path).and_then(|mut f| io::copy(&mut f, sink)) {
540 tracing::warn!(error = %e, path = %path.display(), "download failed");
541 }
542 });
543 (
544 [
545 (header::CONTENT_TYPE, mime.to_string()),
546 (header::CONTENT_LENGTH, len.to_string()),
547 ],
548 body,
549 )
550 .into_response()
551}
552
553/// Stream an archive of `dir` (top-level entry `top`) to the client.
554fn stream_archive(fmt: ArchiveFormat, dir: PathBuf, top: String) -> axum::body::Body {
555 blocking_body(move |sink| {
556 if let Err(e) = archive::build(fmt, &dir, &top, sink) {
557 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
558 }
559 })
560}
561
562/// A response body fed by a blocking writer on the blocking pool.
563fn blocking_body(write: impl FnOnce(&mut ChanWriter) + Send + 'static) -> axum::body::Body {
564 let (tx, mut rx) = mpsc::channel::<Vec<u8>>(16);
565 // Dropping the writer flushes its buffer and closes the channel.
566 tokio::task::spawn_blocking(move || write(&mut ChanWriter::new(tx)));
567 let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)).map(Ok::<_, io::Error>);
568 axum::body::Body::from_stream(stream)
569}
570
571/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
572/// bridge between the blocking archive builder and the async response body.
573struct ChanWriter {
574 tx: mpsc::Sender<Vec<u8>>,
575 buf: Vec<u8>,
576}
577
578impl ChanWriter {
579 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
580 Self {
581 tx,
582 buf: Vec::with_capacity(64 * 1024),
583 }
584 }
585}
586
587impl io::Write for ChanWriter {
588 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
589 self.buf.extend_from_slice(b);
590 if self.buf.len() >= 64 * 1024 {
591 io::Write::flush(self)?;
592 }
593 Ok(b.len())
594 }
595 fn flush(&mut self) -> io::Result<()> {
596 if !self.buf.is_empty() {
597 let chunk = std::mem::take(&mut self.buf);
598 self.tx
599 .blocking_send(chunk)
600 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
601 }
602 Ok(())
603 }
604}
605
606impl Drop for ChanWriter {
607 fn drop(&mut self) {
608 let _ = io::Write::flush(self);
609 }
610}
611
612// ---------------------------------------------------------------------------
613// POST dispatch: mkdir | rename/move/copy | upload
614// ---------------------------------------------------------------------------
615
616/// POST — route one request to upload, mutation or mkdir. On the bare root
617/// only an upload into the root directory makes sense; the JSON ops reject a
618/// missing item name themselves.
619///
620/// Upload and mutation are recognized by their content type. mkdir carries no
621/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
622/// an unrecognized content type used to fall through to mkdir, which turned a
623/// typo in a header into a silently created folder.
624pub async fn dispatch(
625 State(state): State<Arc<AppState>>,
626 auth: AuthUser,
627 AxumPath(Loc {
628 root_id,
629 path: req_rel,
630 }): AxumPath<Loc>,
631 AxumQuery(query): AxumQuery<FileQuery>,
632 headers: HeaderMap,
633 req: axum::http::Request<axum::body::Body>,
634) -> Result<Response, ApiError> {
635 let ct = headers
636 .get(header::CONTENT_TYPE)
637 .and_then(|v| v.to_str().ok())
638 .unwrap_or("");
639
640 if ct.starts_with("multipart/form-data") {
641 return Ok(upload(state, auth, root_id, req_rel, &query, req)
642 .await?
643 .into_response());
644 }
645 if ct.starts_with("application/json") {
646 let is_exists = query.action.as_deref() == Some(api_types::ACTION_EXISTS);
647 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
648 .await
649 .map_err(bad_body)?;
650 if is_exists {
651 let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
652 return Ok(exists(state, auth, root_id, req_rel, body)
653 .await?
654 .into_response());
655 }
656 let body: Mutation = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
657 return Ok(mutation(state, auth, root_id, req_rel, body)
658 .await?
659 .into_response());
660 }
661 let (create, msg, code): (CreateFn, _, _) = match query.action.as_deref() {
662 Some(api_types::ACTION_MKDIR) => (
663 fs::mkdir,
664 "a folder name is required",
665 "err_folder_name_required",
666 ),
667 Some(api_types::ACTION_CREATE_FILE) => (
668 fs::create_file,
669 "a file name is required",
670 "err_file_name_required",
671 ),
672 _ => {
673 return Err(ApiError::localized(
674 StatusCode::UNSUPPORTED_MEDIA_TYPE,
675 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
676 "err_bad_post",
677 ));
678 }
679 };
680 let root = require_rw_root(&auth.roots, root_id)?;
681 if req_rel.trim().is_empty() {
682 return Err(ApiError::localized(StatusCode::BAD_REQUEST, msg, code));
683 }
684 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
685 blocking(move || create(&server_root, &root_rel, &rel)).await?;
686 Ok(Json(OkResp {}).into_response())
687}
688
689type CreateFn = fn(&std::path::Path, &str, &str) -> Result<(), fs::FsError>;
690
691fn bad_body<E>(_: E) -> ApiError {
692 ApiError::localized(
693 StatusCode::BAD_REQUEST,
694 "invalid request body",
695 "err_bad_body",
696 )
697}
698
699// ---------------------------------------------------------------------------
700// rename / move / copy
701// ---------------------------------------------------------------------------
702
703async fn mutation(
704 state: Arc<AppState>,
705 auth: AuthUser,
706 root_id: i64,
707 req_rel: String,
708 body: Mutation,
709) -> Result<Json<OkResp>, ApiError> {
710 match body.op {
711 Op::Rename => {
712 let new_name = body
713 .new_name
714 .as_deref()
715 .ok_or_else(|| {
716 ApiError::localized(
717 StatusCode::BAD_REQUEST,
718 "new_name is required",
719 "err_new_name_required",
720 )
721 })?
722 .to_string();
723 let root = require_rw_root(&auth.roots, root_id)?;
724 let (server_root, root_rel, rel, overwrite) = (
725 state.root.clone(),
726 root.path.clone(),
727 req_rel,
728 body.overwrite,
729 );
730 let vacated = blocking(move || {
731 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
732 })
733 .await?;
734 revoke_shares_at(&state, &vacated).await;
735 Ok(Json(OkResp {}))
736 }
737 Op::Move | Op::Copy => {
738 let dst_root_id = body.dst_root_id.ok_or_else(|| {
739 ApiError::localized(
740 StatusCode::BAD_REQUEST,
741 "dst_root_id is required",
742 "err_dst_required",
743 )
744 })?;
745 let dst = body.dst.clone().unwrap_or_default();
746 // Moving or copying out of a folder requires rw there; copying
747 // *from* a read-only root is fine.
748 let op_is_move = body.op == Op::Move;
749 let src_root = if op_is_move {
750 require_rw_root(&auth.roots, root_id)?
751 } else {
752 find_root(&auth.roots, root_id)?
753 };
754 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
755 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
756 state.root.clone(),
757 src_root.path.clone(),
758 dst_root.path.clone(),
759 dst,
760 req_rel,
761 body.overwrite,
762 );
763 let vacated = blocking(move || {
764 if op_is_move {
765 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
766 .map(Some)
767 } else {
768 // A copy frees no path, so it revokes nothing.
769 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
770 .map(|()| None)
771 }
772 })
773 .await?;
774 if let Some(vacated) = vacated {
775 revoke_shares_at(&state, &vacated).await;
776 }
777 Ok(Json(OkResp {}))
778 }
779 }
780}
781
782// ---------------------------------------------------------------------------
783// DELETE
784// ---------------------------------------------------------------------------
785
786pub async fn delete(
787 State(state): State<Arc<AppState>>,
788 auth: AuthUser,
789 AxumPath(Loc {
790 root_id,
791 path: req_rel,
792 }): AxumPath<Loc>,
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 path inside the folder is required",
799 "err_path_required",
800 ));
801 }
802 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
803 let gone = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
804 revoke_shares_at(&state, &gone).await;
805 Ok(Json(OkResp {}))
806}
807
808// ---------------------------------------------------------------------------
809// Upload (multipart)
810// ---------------------------------------------------------------------------
811
812fn bad_upload<E>(_: E) -> ApiError {
813 ApiError::localized(
814 StatusCode::BAD_REQUEST,
815 "invalid upload data",
816 "err_bad_upload",
817 )
818}
819
820async fn upload(
821 state: Arc<AppState>,
822 auth: AuthUser,
823 root_id: i64,
824 req_rel: String,
825 query: &FileQuery,
826 req: axum::http::Request<axum::body::Body>,
827) -> Result<Json<OkResp>, ApiError> {
828 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
829 let boundary = req
830 .headers()
831 .get(header::CONTENT_TYPE)
832 .and_then(|v| v.to_str().ok())
833 .and_then(|ct| multer::parse_boundary(ct).ok())
834 .ok_or_else(|| {
835 ApiError::localized(
836 StatusCode::BAD_REQUEST,
837 "expected multipart/form-data with a boundary",
838 "err_bad_multipart",
839 )
840 })?;
841 let overwrite = query.overwrite();
842
843 let stream = req.into_body().into_data_stream();
844 let mut multipart = Multipart::new(stream, boundary);
845 let mut uploaded: usize = 0;
846 let mut skipped: Vec<String> = Vec::new();
847
848 while let Some(mut field) = multipart.next_field().await.map_err(bad_upload)? {
849 let part_name = field
850 .name()
851 .filter(|n| !n.is_empty())
852 .or_else(|| field.file_name())
853 .map(decode_cd)
854 .ok_or_else(|| {
855 ApiError::localized(
856 StatusCode::BAD_REQUEST,
857 "part without a name",
858 "err_part_no_name",
859 )
860 })?;
861
862 validate_rel_path(&part_name)?;
863
864 let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone());
865 let target = blocking(move || upload_target(&r, &b, &rel, true).map_err(target_error))
866 .await?
867 .expect("create_parent resolves every parent");
868 let (exists, is_dir) = (target.exists, target.is_dir);
869 let target = target.path;
870
871 // A folder can never be replaced by a file, even with `overwrite`.
872 // Report it like a conflict so the client fails this one part, not
873 // the request: a rejected request makes it retry every other file
874 // alone.
875 if exists && (!overwrite || is_dir) {
876 // Drain this part and report it as a conflict at the end.
877 while field.chunk().await.map_err(bad_upload)?.is_some() {}
878 skipped.push(part_name);
879 continue;
880 }
881
882 // Stream to a temp file in the same directory, then publish.
883 let suffix = crate::auth::random_token();
884 let tmp = Scratch(
885 target
886 .parent()
887 .expect("has parent")
888 .join(format!(".upload-{suffix}")),
889 );
890 let tmp_file = tokio::fs::File::create(&tmp.0).await?;
891 // Buffered: a multipart chunk is often a few kilobytes, and each
892 // unbuffered write would be its own syscall.
893 let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file);
894 let write_failed = loop {
895 match field.chunk().await.map_err(bad_upload)? {
896 Some(chunk) => {
897 if let Err(e) = tmp_file.write_all(&chunk).await {
898 tracing::warn!(error = %e, "write failed during upload");
899 break true;
900 }
901 }
902 None => break tmp_file.flush().await.is_err(),
903 }
904 };
905 if write_failed {
906 return Err(ApiError::localized(
907 StatusCode::INTERNAL_SERVER_ERROR,
908 "could not save the file",
909 "err_save_failed",
910 ));
911 }
912 let tmp_path = tmp.0.clone();
913 let published = blocking(move || {
914 publish(&tmp_path, &target, overwrite).map_err(|e| {
915 tracing::warn!(error = %e, path = %target.display(), "publish failed during upload");
916 ApiError::internal()
917 })
918 })
919 .await?;
920 if let Published::Exists = published {
921 // The target appeared while the body streamed in. The drop guard
922 // removes the scratch file.
923 skipped.push(part_name);
924 continue;
925 }
926 tmp.disarm();
927 uploaded += 1;
928 }
929
930 if uploaded == 0 && skipped.is_empty() {
931 return Err(ApiError::localized(
932 StatusCode::BAD_REQUEST,
933 "no files were uploaded",
934 "err_no_files_uploaded",
935 ));
936 }
937 if !skipped.is_empty() {
938 return Err(ApiError::localized(
939 StatusCode::CONFLICT,
940 "some files already exist",
941 "err_files_exist",
942 )
943 .with_extra(serde_json::Map::from_iter([
944 ("skipped".into(), serde_json::json!(skipped)),
945 ("uploaded".into(), serde_json::json!(uploaded)),
946 ])));
947 }
948 Ok(Json(OkResp {}))
949}
950
951/// The rw root and the upload directory. `root_abs` is the containment
952/// boundary (a symlink may legitimately point elsewhere inside it), `base`
953/// the directory the request names.
954async fn upload_base(
955 state: &AppState,
956 auth: &AuthUser,
957 root_id: i64,
958 req_rel: String,
959) -> Result<(PathBuf, PathBuf), ApiError> {
960 let root = require_rw_root(&auth.roots, root_id)?;
961 let (server_root, root_rel) = (state.root.clone(), root.path.clone());
962 blocking(move || {
963 Ok::<_, FsError>((
964 fs::resolve_root(&server_root, &root_rel)?,
965 fs::resolve_dir(&server_root, &root_rel, &req_rel)?,
966 ))
967 })
968 .await
969}
970
971/// One upload target on disk. `path` has a canonical parent that is inside
972/// the root.
973struct Target {
974 path: PathBuf,
975 exists: bool,
976 is_dir: bool,
977}
978
979/// Resolve `rel` under `base` for an upload. The nearest existing ancestor
980/// and the final parent are both checked against `root_abs`, so nothing is
981/// created or written outside the root even through a symlinked directory.
982/// With `create_parent` missing directories are created. Without it a
983/// missing parent returns `None`: the target cannot exist.
984///
985/// `exists` uses `symlink_metadata`, so a dangling symlink counts as
986/// existing and is not silently replaced.
987fn upload_target(
988 root_abs: &Path,
989 base: &Path,
990 rel: &str,
991 create_parent: bool,
992) -> io::Result<Option<Target>> {
993 let escape = || io::Error::other("upload parent escapes the root");
994 let full = base.join(rel);
995 let (Some(parent), Some(name)) = (
996 full.parent().filter(|p| !p.as_os_str().is_empty()),
997 full.file_name(),
998 ) else {
999 return Err(io::Error::new(
1000 io::ErrorKind::InvalidInput,
1001 "invalid part name",
1002 ));
1003 };
1004 let mut existing = parent;
1005 while !existing.exists() {
1006 existing = existing.parent().ok_or_else(escape)?;
1007 }
1008 if !fs::is_within_or_eq(root_abs, &existing.canonicalize()?) {
1009 return Err(escape());
1010 }
1011 if !parent.is_dir() {
1012 if !create_parent {
1013 return Ok(None);
1014 }
1015 std::fs::create_dir_all(parent)?;
1016 }
1017 let canon = parent.canonicalize()?;
1018 if !fs::is_within_or_eq(root_abs, &canon) {
1019 return Err(escape());
1020 }
1021 let path = canon.join(name);
1022 Ok(Some(Target {
1023 exists: std::fs::symlink_metadata(&path).is_ok(),
1024 is_dir: std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
1025 path,
1026 }))
1027}
1028
1029/// Map an [`upload_target`] error to the API error: a malformed name is a
1030/// 400, everything else (escape, io) a 403 as before.
1031fn target_error(e: io::Error) -> ApiError {
1032 if e.kind() == io::ErrorKind::InvalidInput {
1033 return ApiError::localized(
1034 StatusCode::BAD_REQUEST,
1035 "invalid part name",
1036 "err_bad_part_name",
1037 );
1038 }
1039 tracing::warn!(error = %e, "upload parent rejected");
1040 ApiError::localized(
1041 StatusCode::FORBIDDEN,
1042 "invalid file path in upload",
1043 "err_bad_upload_path",
1044 )
1045}
1046
1047/// `POST /api/files/{root_id}/{*path}?action=exists` — which upload targets
1048/// already exist. Read-only: no directory is created. The client asks this
1049/// before uploading so the overwrite question comes before the transfer.
1050async fn exists(
1051 state: Arc<AppState>,
1052 auth: AuthUser,
1053 root_id: i64,
1054 req_rel: String,
1055 body: ExistsReq,
1056) -> Result<Json<ExistsResp>, ApiError> {
1057 if body.paths.len() > 10_000 {
1058 return Err(ApiError::localized(
1059 StatusCode::BAD_REQUEST,
1060 "too many paths",
1061 "err_too_many_paths",
1062 ));
1063 }
1064 for p in &body.paths {
1065 validate_rel_path(p)?;
1066 }
1067 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
1068 let existing = blocking(move || {
1069 let mut out = Vec::new();
1070 for path in body.paths {
1071 if let Some(t) = upload_target(&root_abs, &base, &path, false).map_err(target_error)?
1072 && t.exists
1073 {
1074 out.push(Existing {
1075 path,
1076 is_dir: t.is_dir,
1077 });
1078 }
1079 }
1080 Ok::<_, ApiError>(out)
1081 })
1082 .await?;
1083 Ok(Json(ExistsResp { existing }))
1084}
1085
1086/// Outcome of [`publish`].
1087enum Published {
1088 Written,
1089 /// The target exists and `overwrite` was off. Nothing was replaced.
1090 Exists,
1091}
1092
1093/// Move the finished scratch file to `target`. With `overwrite`, `rename`
1094/// replaces whatever is there. Without it the target must not exist at the
1095/// moment of publishing: `hard_link` fails with `AlreadyExists` atomically,
1096/// which closes the window between the pre-upload stat and the publish.
1097/// On success `tmp` is gone in both cases.
1098fn publish(tmp: &Path, target: &Path, overwrite: bool) -> io::Result<Published> {
1099 if overwrite {
1100 std::fs::rename(tmp, target)?;
1101 return Ok(Published::Written);
1102 }
1103 match std::fs::hard_link(tmp, target) {
1104 Ok(()) => {
1105 std::fs::remove_file(tmp)?;
1106 Ok(Published::Written)
1107 }
1108 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(Published::Exists),
1109 Err(e) if link_unsupported(&e) => {
1110 tracing::warn!(error = %e, "hard links unsupported here, falling back to stat + rename");
1111 // ponytail: stat-then-rename leaves a microsecond window in which
1112 // a file created by someone else is replaced. Closing it needs
1113 // renameat2(RENAME_NOREPLACE) through libc.
1114 if std::fs::symlink_metadata(target).is_ok() {
1115 return Ok(Published::Exists);
1116 }
1117 std::fs::rename(tmp, target)?;
1118 Ok(Published::Written)
1119 }
1120 Err(e) => Err(e),
1121 }
1122}
1123
1124/// The filesystem refuses hard links: EPERM (some network mounts, restricted
1125/// namespaces), ENOTSUP, or EXDEV. Only EXDEV needs its raw code; the other
1126/// two map to an `ErrorKind`.
1127fn link_unsupported(e: &io::Error) -> bool {
1128 matches!(
1129 e.kind(),
1130 io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
1131 ) || e.raw_os_error() == Some(18)
1132}
1133
1134/// Undo the client's `Content-Disposition` escaping (WHATWG form-data): the
1135/// three characters that cannot appear raw in a quoted header value. `multer`
1136/// does not do this itself.
1137fn decode_cd(s: &str) -> String {
1138 s.replace("%22", "\"")
1139 .replace("%0D", "\r")
1140 .replace("%0A", "\n")
1141}
1142
1143/// The `.upload-<token>` scratch file of one in-flight upload part. Dropping it
1144/// removes the file, which covers the paths no `return` can see, above all the
1145/// request future being dropped when the client closes the connection. A leaked
1146/// scratch file is never named again and shows up in listings, which include
1147/// hidden entries on purpose. [`Scratch::disarm`] after a publish skips the
1148/// unlink of a path that is now the uploaded file.
1149struct Scratch(PathBuf);
1150
1151impl Scratch {
1152 /// The scratch file is now the uploaded file: leave it alone.
1153 fn disarm(self) {
1154 std::mem::forget(self);
1155 }
1156}
1157
1158impl Drop for Scratch {
1159 fn drop(&mut self) {
1160 // Plain blocking unlink: `Drop` can run during runtime shutdown, where
1161 // `tokio::spawn` panics. One unlink cannot block meaningfully.
1162 if let Err(e) = std::fs::remove_file(&self.0)
1163 && e.kind() != io::ErrorKind::NotFound
1164 {
1165 tracing::warn!(error = %e, path = %self.0.display(), "could not remove upload scratch file");
1166 }
1167 }
1168}
1169
1170fn validate_rel_path(name: &str) -> Result<(), ApiError> {
1171 if std::path::Path::new(name)
1172 .components()
1173 .all(|c| matches!(c, Component::Normal(_)))
1174 {
1175 return Ok(());
1176 }
1177 Err(ApiError::localized(
1178 StatusCode::BAD_REQUEST,
1179 "invalid file path in upload",
1180 "err_bad_upload_path",
1181 ))
1182}
1183
1184// ---------------------------------------------------------------------------
1185// Helpers
1186// ---------------------------------------------------------------------------
1187
1188/// Drop every share that named `abs` or anything under it.
1189///
1190/// Called after a delete, a rename, or a move: each one frees a path, and a
1191/// share stores a path, not a file identity. Without this, a *new* item that
1192/// later lands on the freed path would inherit the old link's audience.
1193///
1194/// Only covers changes made through this API. A file moved out from under the
1195/// server (over SSH, say) leaves its shares in place, still pointing at a
1196/// path. Closing that needs inode pinning, which breaks across a restore from
1197/// backup, so it is deliberately not done.
1198///
1199/// Best-effort: the file operation has already succeeded by the time this
1200/// runs, so a database error must not turn it into a 500. The client would
1201/// read that as "the delete failed" and retry, and the retry would 404. The
1202/// failure is logged at `error` instead, and leaves a share pointing at a
1203/// path that no longer holds what it did.
1204pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
1205 let target = target_rel(state, abs);
1206 match state.db.revoke_shares_at(&target).await {
1207 Ok(0) => {}
1208 Ok(n) => tracing::info!(target = %target, revoked = n, "shares revoked: path is gone"),
1209 Err(e) => {
1210 tracing::error!(error = %e, target = %target, "could not revoke shares on a freed path")
1211 }
1212 }
1213}
1214
1215pub(crate) fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1216 roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
1217 ApiError::localized(
1218 StatusCode::FORBIDDEN,
1219 "no such folder",
1220 "err_no_such_folder",
1221 )
1222 })
1223}
1224
1225fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1226 let root = find_root(roots, root_id)?;
1227 if !root.mode.is_writable() {
1228 return Err(ApiError::localized(
1229 StatusCode::FORBIDDEN,
1230 "read-only folder",
1231 "err_read_only_folder",
1232 ));
1233 }
1234 Ok(root)
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use api_types::{
1241 ACTION_DOWNLOAD, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET,
1242 P_OVERWRITE, P_SORT,
1243 };
1244 use axum::http::Uri;
1245
1246 /// The publish step must never replace a file that appeared after the
1247 /// pre-upload stat unless `overwrite` is on.
1248 #[test]
1249 fn publish_refuses_an_existing_target_without_overwrite() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let tmp = dir.path().join(".upload-1");
1252 let target = dir.path().join("a.txt");
1253
1254 std::fs::write(&tmp, b"new").unwrap();
1255 assert!(matches!(
1256 publish(&tmp, &target, false).unwrap(),
1257 Published::Written
1258 ));
1259 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1260 assert!(!tmp.exists(), "scratch file must be gone after publish");
1261
1262 // The target exists now: no overwrite → untouched.
1263 std::fs::write(&tmp, b"racer").unwrap();
1264 assert!(matches!(
1265 publish(&tmp, &target, false).unwrap(),
1266 Published::Exists
1267 ));
1268 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1269 assert!(
1270 tmp.exists(),
1271 "the caller's drop guard removes the scratch file"
1272 );
1273
1274 // With overwrite the target is replaced.
1275 assert!(matches!(
1276 publish(&tmp, &target, true).unwrap(),
1277 Published::Written
1278 ));
1279 assert_eq!(std::fs::read(&target).unwrap(), b"racer");
1280 assert!(!tmp.exists());
1281 }
1282
1283 /// A rename of a `P_*` constant without the matching field
1284 /// rename would silently stop the server from reading the parameter the
1285 /// client sends. This builds the query string from the constants and
1286 /// runs the real extractor over it.
1287 #[test]
1288 fn query_fields_are_the_shared_constants() {
1289 let uri: Uri = format!("/f/1/a.txt?{P_ACTION}={ACTION_DOWNLOAD}&{P_FORMAT}=zip")
1290 .parse()
1291 .unwrap();
1292 let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
1293 assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD));
1294 assert_eq!(q.format.as_deref(), Some("zip"));
1295 let list_uri: Uri =
1296 format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true&{P_AROUND}=a.txt")
1297 .parse()
1298 .unwrap();
1299 let q: FileQuery = AxumQuery::try_from_uri(&list_uri).unwrap().0;
1300 assert_eq!(
1301 (
1302 q.sort,
1303 q.desc,
1304 q.offset,
1305 q.limit,
1306 q.dirs,
1307 q.around.as_deref()
1308 ),
1309 (SortKey::Size, true, 5, Some(10), true, Some("a.txt"))
1310 );
1311
1312 let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=1").parse().unwrap();
1313 let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
1314 assert!(q.overwrite());
1315 }
1316}
1317