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::{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 tokio_stream::wrappers::ReceiverStream;
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 DeleteResp, Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, P_ACTION,
35 P_OVERWRITE, SaveResp, SortKey, UploadResp,
36};
37
38/// Upper bound for the in-memory text endpoint (preview, later editor).
39pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
40
41// ---------------------------------------------------------------------------
42// Query params
43// ---------------------------------------------------------------------------
44
45/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
46/// route lists the directory, and the listing params pick the page;
47/// `?action=download|preview|content` serve the item itself.
48///
49/// The field names are the shared `P_*` constants.
50/// `#[serde(rename)]` only takes a literal, so that link cannot be written
51/// here; `tests::query_fields_are_the_shared_constants` pins it instead.
52#[derive(Deserialize, Default)]
53pub struct FileQuery {
54 #[serde(default)]
55 action: Option<String>,
56 #[serde(default)]
57 format: Option<String>,
58 #[serde(default)]
59 sort: SortKey,
60 #[serde(default)]
61 desc: bool,
62 #[serde(default)]
63 offset: usize,
64 #[serde(default)]
65 limit: Option<usize>,
66 #[serde(default)]
67 dirs: bool,
68 #[serde(default)]
69 around: Option<String>,
70}
71
72// ---------------------------------------------------------------------------
73// Listing
74// ---------------------------------------------------------------------------
75
76/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
77/// itself via `?action=download|preview|content`.
78pub async fn file_get(
79 State(state): State<Arc<AppState>>,
80 auth: AuthUser,
81 path: AxumPath<(i64, String)>,
82 query: AxumQuery<FileQuery>,
83 headers: axum::http::HeaderMap,
84) -> Result<Response, ApiError> {
85 let (root_id, req_rel) = path.0;
86 match query.action.as_deref() {
87 Some(a) if a == api_types::ACTION_DOWNLOAD => {
88 let range = range_header(&headers);
89 download(
90 state,
91 auth,
92 root_id,
93 req_rel,
94 query.format.as_deref(),
95 range,
96 ims_header(&headers),
97 )
98 .await
99 }
100 Some(a) if a == api_types::ACTION_PREVIEW => {
101 preview(
102 state,
103 auth,
104 root_id,
105 req_rel,
106 range_header(&headers),
107 ims_header(&headers),
108 )
109 .await
110 }
111 Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
112 Some(a) if a == api_types::ACTION_THUMB => thumb(state, auth, root_id, req_rel).await,
113 _ => {
114 let opts = fs::ListOpts {
115 sort: query.sort,
116 desc: query.desc,
117 offset: query.offset,
118 limit: query.limit,
119 dirs_only: query.dirs,
120 around: query.around.clone(),
121 };
122 let json = list_inner(state, auth, root_id, req_rel, opts).await?;
123 Ok(json.into_response())
124 }
125 }
126}
127
128/// GET /api/files/{root_id} — list the root directory itself, or serve the
129/// root item via `?action=download|preview|content` (the root of a *file*
130/// share is the file itself).
131///
132/// Same thing as [`file_get`] with an empty path, so it delegates there.
133pub async fn list_root(
134 state: State<Arc<AppState>>,
135 auth: AuthUser,
136 path: AxumPath<i64>,
137 query: AxumQuery<FileQuery>,
138 headers: axum::http::HeaderMap,
139) -> Result<Response, ApiError> {
140 file_get(
141 state,
142 auth,
143 AxumPath((path.0, String::new())),
144 query,
145 headers,
146 )
147 .await
148}
149
150fn range_header(headers: &axum::http::HeaderMap) -> Option<String> {
151 headers
152 .get(header::RANGE)
153 .and_then(|v| v.to_str().ok())
154 .map(str::to_string)
155}
156
157fn ims_header(headers: &axum::http::HeaderMap) -> Option<String> {
158 headers
159 .get(header::IF_MODIFIED_SINCE)
160 .and_then(|v| v.to_str().ok())
161 .map(str::to_string)
162}
163
164/// Caching policy for a served file.
165///
166/// `private` because the response depends on who asked. `no-cache`, not
167/// `no-store`, so a client can revalidate a large media file and get a `304`
168/// instead of re-downloading it.
169const FILE_CACHE: &str = "private, no-cache";
170
171/// An mtime (unix seconds) as an HTTP-date, the `Last-Modified` format.
172/// None for an unknown mtime (0) and for a file written in the last two
173/// seconds: whole-second dates cannot tell two writes in one second apart.
174fn http_date(mtime: i64) -> Option<String> {
175 if mtime <= 0 || mtime + 2 > chrono::Utc::now().timestamp() {
176 return None;
177 }
178 chrono::DateTime::from_timestamp(mtime, 0)
179 .map(|d| d.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
180}
181
182/// True when the client's `If-Modified-Since` is at or after `mtime`.
183/// HTTP-dates carry whole seconds, so the comparison is second-precision.
184fn unmodified_since(ims: Option<&str>, mtime: i64) -> bool {
185 chrono::DateTime::parse_from_rfc2822(ims.unwrap_or_default())
186 .is_ok_and(|t| t.timestamp() >= mtime)
187}
188
189async fn list_inner(
190 state: Arc<AppState>,
191 auth: AuthUser,
192 root_id: i64,
193 req_rel: String,
194 opts: fs::ListOpts,
195) -> Result<Json<FilesResp>, ApiError> {
196 // A file share's root is the file itself: there is nothing to list.
197 if auth.share.as_ref().is_some_and(|s| s.is_file) {
198 return Err(FsError::NotADirectory.into());
199 }
200 let root = find_root(&auth.roots, root_id)?;
201 let server_root = state.root.clone();
202 let root_rel = root.path.clone();
203 // Resolve and list in one blocking hop: both are filesystem work.
204 let resp = blocking(move || {
205 let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?;
206 fs::list_dir(&full, opts)
207 })
208 .await?;
209
210 Ok(Json(resp))
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 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}
267
268/// `GET ...?action=download` — a single file as-is, a folder as an archive
269/// (format chosen by the client).
270async fn download(
271 state: Arc<AppState>,
272 auth: AuthUser,
273 root_id: i64,
274 req_rel: String,
275 format: Option<&str>,
276 range: Option<String>,
277 ims: Option<String>,
278) -> Result<Response, ApiError> {
279 let root = find_root(&auth.roots, root_id)?;
280 let (full, name, is_dir, size, mtime) =
281 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
282
283 if !is_dir {
284 return file_response(
285 &full,
286 &name,
287 size,
288 false,
289 range.as_deref(),
290 mtime,
291 ims.as_deref(),
292 )
293 .await;
294 }
295
296 let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
297 ApiError::localized(
298 StatusCode::BAD_REQUEST,
299 "format must be one of: zip, tar, tar.gz, tar.zst",
300 "err_bad_format",
301 )
302 })?;
303 let disp = disposition("attachment", &format!("{name}.{}", fmt.extension()));
304 let body = stream_archive(fmt, full, name);
305 Response::builder()
306 .status(StatusCode::OK)
307 .header(header::CONTENT_TYPE, fmt.mime())
308 .header(header::CONTENT_DISPOSITION, disp)
309 .header(header::CACHE_CONTROL, FILE_CACHE)
310 .body(body)
311 .map_err(|e| {
312 ApiError::new(
313 StatusCode::INTERNAL_SERVER_ERROR,
314 format!("bad response: {e}"),
315 )
316 })
317}
318
319/// `GET ...?action=preview` — a single file, inline (for native media).
320async fn preview(
321 state: Arc<AppState>,
322 auth: AuthUser,
323 root_id: i64,
324 req_rel: String,
325 range: Option<String>,
326 ims: Option<String>,
327) -> Result<Response, ApiError> {
328 let root = find_root(&auth.roots, root_id)?;
329 let (full, name, is_dir, size, mtime) =
330 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
331 if is_dir {
332 return Err(ApiError::localized(
333 StatusCode::BAD_REQUEST,
334 "not a file",
335 "err_not_a_file",
336 ));
337 }
338 file_response(
339 &full,
340 &name,
341 size,
342 true,
343 range.as_deref(),
344 mtime,
345 ims.as_deref(),
346 )
347 .await
348}
349
350/// `GET ...?action=content` — raw file bytes for the text preview/editor.
351/// Capped at `MAX_TEXT_BYTES`.
352async fn content(
353 state: Arc<AppState>,
354 auth: AuthUser,
355 root_id: i64,
356 req_rel: String,
357) -> Result<Response, ApiError> {
358 let root = find_root(&auth.roots, root_id)?;
359 let (full, _name, is_dir, size, _mtime) =
360 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
361 if is_dir {
362 return Err(ApiError::localized(
363 StatusCode::BAD_REQUEST,
364 "not a file",
365 "err_not_a_file",
366 ));
367 }
368 if size > MAX_TEXT_BYTES {
369 return Err(ApiError::localized(
370 StatusCode::PAYLOAD_TOO_LARGE,
371 "file too large to preview",
372 "err_too_large_preview",
373 ));
374 }
375 // mtime and bytes from one handle, mtime first: a write in between would
376 // otherwise hand the editor a stale conflict anchor for fresh content.
377 let (mtime, bytes) = blocking(move || -> io::Result<(i64, Vec<u8>)> {
378 let mut f = std::fs::File::open(&full)?;
379 let mtime = fs::mtime_secs(&f.metadata()?).unwrap_or(0);
380 let mut bytes = Vec::with_capacity(size as usize);
381 f.read_to_end(&mut bytes)?;
382 Ok((mtime, bytes))
383 })
384 .await?;
385 Ok((
386 [
387 (
388 header::CONTENT_TYPE,
389 "text/plain; charset=utf-8".to_string(),
390 ),
391 (header::CACHE_CONTROL, FILE_CACHE.to_string()),
392 (
393 axum::http::HeaderName::from_static("x-file-mtime"),
394 mtime.to_string(),
395 ),
396 ],
397 bytes,
398 )
399 .into_response())
400}
401
402/// `GET ...?action=thumb` — a small WebP preview of an image or video.
403///
404/// `404` covers every "no thumbnail here" case: thumbnails switched off, a
405/// folder, a kind we do not render, an undecodable file, a video without
406/// ffmpeg. The grid falls back to its icon for all of them alike.
407async fn thumb(
408 state: Arc<AppState>,
409 auth: AuthUser,
410 root_id: i64,
411 req_rel: String,
412) -> Result<Response, ApiError> {
413 let Some(thumbs) = state.thumbs.as_ref() else {
414 return Err(no_thumb());
415 };
416 let root = find_root(&auth.roots, root_id)?;
417 let (full, _name, is_dir, size, mtime) =
418 resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
419 if is_dir {
420 return Err(no_thumb());
421 }
422 // The kind is sniffed from the bytes, not the name, so an mp4 called .txt
423 // still gets a thumbnail and a .jpg full of text does not.
424 let kind = {
425 let full = full.clone();
426 blocking(move || Ok::<_, FsError>(fs::detect_kind(&full, false))).await?
427 };
428 let Some(bytes) = thumbs.get(&full, kind, size, mtime).await else {
429 return Err(no_thumb());
430 };
431 Ok((
432 [
433 (header::CONTENT_TYPE, "image/webp"),
434 // Safe despite the stable path: the client varies the query on
435 // mtime, so new content always means a new URL.
436 (
437 header::CACHE_CONTROL,
438 "private, max-age=31536000, immutable",
439 ),
440 ],
441 bytes,
442 )
443 .into_response())
444}
445
446/// The message never reaches a user: an `<img>` 404 just leaves the tile's
447/// icon showing. That is why it carries no localized code.
448fn no_thumb() -> ApiError {
449 ApiError::new(StatusCode::NOT_FOUND, "no thumbnail".to_string())
450}
451
452/// `PUT ...?action=content` — save a file's text contents (the editor).
453///
454/// Requires a read-write root. The body is the new contents. If the
455/// `X-Expected-Mtime` header is present, the file's current mtime must match
456/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
457/// Returns the file's new mtime so the client can anchor the next check.
458pub async fn file_put(
459 State(state): State<Arc<AppState>>,
460 auth: AuthUser,
461 path: AxumPath<(i64, String)>,
462 query: AxumQuery<FileQuery>,
463 headers: axum::http::HeaderMap,
464 body: axum::body::Bytes,
465) -> Result<Json<SaveResp>, ApiError> {
466 let (root_id, req_rel) = path.0;
467 put_inner(state, auth, root_id, req_rel, query, headers, body).await
468}
469
470/// `PUT .../{root_id}?action=content` — the root item itself. Only reachable
471/// for a *file* share (its root is the file); for folders it resolves to a
472/// directory and is rejected below.
473pub async fn file_put_root(
474 State(state): State<Arc<AppState>>,
475 auth: AuthUser,
476 path: AxumPath<i64>,
477 query: AxumQuery<FileQuery>,
478 headers: axum::http::HeaderMap,
479 body: axum::body::Bytes,
480) -> Result<Json<SaveResp>, ApiError> {
481 put_inner(state, auth, path.0, String::new(), query, headers, body).await
482}
483
484async fn put_inner(
485 state: Arc<AppState>,
486 auth: AuthUser,
487 root_id: i64,
488 req_rel: String,
489 query: AxumQuery<FileQuery>,
490 headers: axum::http::HeaderMap,
491 body: axum::body::Bytes,
492) -> Result<Json<SaveResp>, ApiError> {
493 if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
494 return Err(ApiError::localized(
495 StatusCode::BAD_REQUEST,
496 "expected action=content",
497 "err_bad_action",
498 ));
499 }
500 let root = require_rw_root(&auth.roots, root_id)?;
501 if body.len() as u64 > MAX_TEXT_BYTES {
502 return Err(ApiError::localized(
503 StatusCode::PAYLOAD_TOO_LARGE,
504 "file too large to save",
505 "err_too_large_save",
506 ));
507 }
508 let expected: Option<i64> = headers
509 .get("x-expected-mtime")
510 .and_then(|v| v.to_str().ok())
511 .and_then(|s| s.parse().ok());
512 // A file share's synthetic root *is* the file, so resolve it directly.
513 let share_target = auth
514 .share
515 .as_ref()
516 .filter(|s| s.is_file)
517 .map(|s| s.target.clone());
518 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
519 let content = body.to_vec();
520 let mtime = blocking(move || match share_target {
521 Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
522 None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
523 })
524 .await?;
525 Ok(Json(SaveResp { mtime }))
526}
527
528/// Stream a single file to the client with the right disposition.
529async fn file_response(
530 full: &std::path::Path,
531 name: &str,
532 size: u64,
533 inline: bool,
534 range: Option<&str>,
535 mtime: i64,
536 ims: Option<&str>,
537) -> Result<Response, ApiError> {
538 let last_modified = http_date(mtime);
539 // A revalidating client gets the empty 304 instead of the whole file. The
540 // policy is repeated on it, so the stored copy does not lose the directive.
541 if last_modified.is_some() && unmodified_since(ims, mtime) {
542 return Ok((
543 StatusCode::NOT_MODIFIED,
544 [(header::CACHE_CONTROL, FILE_CACHE)],
545 )
546 .into_response());
547 }
548 let mime = mime_guess::from_path(full)
549 .first_or_octet_stream()
550 .to_string();
551 let disp = disposition(if inline { "inline" } else { "attachment" }, name);
552 // A single `bytes=a-b` range (media seeking). Anything else is served whole.
553 let parsed = parse_range(range, size);
554 let (start, end) = match parsed {
555 Some(Some(r)) => r,
556 Some(None) => {
557 return Response::builder()
558 .status(StatusCode::RANGE_NOT_SATISFIABLE)
559 .header(header::CONTENT_RANGE, format!("bytes */{size}"))
560 .body(axum::body::Body::empty())
561 .map_err(|e| {
562 ApiError::new(
563 StatusCode::INTERNAL_SERVER_ERROR,
564 format!("bad response: {e}"),
565 )
566 });
567 }
568 None => (0, size),
569 };
570 // 206 for every satisfiable `Range`, even one that spans the whole file
571 // (`bytes=0-`, the first request Firefox makes). Firefox reads a 200 as
572 // "no range support" and its media cache stops fetching mid-file.
573 let partial = matches!(parsed, Some(Some(_)));
574 let body = stream_file(full.to_path_buf(), start, end);
575 let mut res = Response::builder()
576 .status(if partial {
577 StatusCode::PARTIAL_CONTENT
578 } else {
579 StatusCode::OK
580 })
581 .header(header::CONTENT_DISPOSITION, disp)
582 .header(header::ACCEPT_RANGES, "bytes")
583 .header(header::CONTENT_LENGTH, end - start)
584 // Always sent, validator or not: with no directive a cache may apply
585 // heuristic freshness to a response that depends on who asked.
586 .header(header::CACHE_CONTROL, FILE_CACHE);
587 if let Some(lm) = last_modified {
588 // The validator the `no-cache` above revalidates against.
589 res = res.header(header::LAST_MODIFIED, lm);
590 }
591 if partial {
592 res = res.header(
593 header::CONTENT_RANGE,
594 format!("bytes {start}-{}/{size}", end - 1),
595 );
596 }
597 // A file the browser would parse as a document (HTML/SVG/XML) is served
598 // under the sandboxed policy, so it can render as a page without being
599 // able to act as the app. Derived from the same `mime` we declare.
600 // Non-scriptable inline files (PDF, …) are frameable by the app itself,
601 // for the preview modal.
602 if crate::api::is_scriptable_mime(&mime) {
603 res = res.header("content-security-policy", crate::api::FILE_CSP);
604 } else if inline {
605 res = res
606 .header("content-security-policy", crate::api::INLINE_CSP)
607 .header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
608 }
609 res.header(header::CONTENT_TYPE, mime)
610 .body(body)
611 .map_err(|e| {
612 ApiError::new(
613 StatusCode::INTERNAL_SERVER_ERROR,
614 format!("bad response: {e}"),
615 )
616 })
617}
618
619/// Parse a `Range` header against `size`. `None` = serve the whole file,
620/// `Some(None)` = unsatisfiable, `Some(Some((start, end)))` = half-open range.
621fn parse_range(range: Option<&str>, size: u64) -> Option<Option<(u64, u64)>> {
622 let spec = range?.strip_prefix("bytes=")?;
623 // ponytail: one range only; multipart/byteranges is not worth it here.
624 let (a, b) = spec.split_once('-')?;
625 let (start, end) = match (a.trim().parse::<u64>().ok(), b.trim().parse::<u64>().ok()) {
626 (Some(s), Some(e)) => (s, e.saturating_add(1).min(size)),
627 (Some(s), None) if b.trim().is_empty() => (s, size),
628 // Suffix form: the last N bytes.
629 (None, Some(n)) if a.trim().is_empty() => (size.saturating_sub(n), size),
630 _ => return None,
631 };
632 if start >= size || start >= end {
633 return Some(None);
634 }
635 Some(Some((start, end)))
636}
637
638/// Stream `path[start..end)` to the client in chunks (blocking reader → channel).
639fn stream_file(path: std::path::PathBuf, start: u64, end: u64) -> axum::body::Body {
640 use std::io::Seek;
641 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
642 tokio::task::spawn_blocking(move || {
643 let mut f = match std::fs::File::open(&path) {
644 Ok(f) => f,
645 Err(e) => {
646 tracing::warn!(error = %e, path = %path.display(), "download open failed");
647 return;
648 }
649 };
650 if start > 0 && f.seek(io::SeekFrom::Start(start)).is_err() {
651 return;
652 }
653 let mut left = end - start;
654 let mut buf = vec![0u8; 256 * 1024];
655 while left > 0 {
656 let want = buf.len().min(left as usize);
657 match f.read(&mut buf[..want]) {
658 Ok(0) => break,
659 Ok(n) => {
660 left -= n as u64;
661 // Client gone → stop producing.
662 if tx.blocking_send(buf[..n].to_vec()).is_err() {
663 break;
664 }
665 }
666 Err(e) => {
667 tracing::warn!(error = %e, path = %path.display(), "download read failed");
668 break;
669 }
670 }
671 }
672 });
673 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
674 axum::body::Body::from_stream(stream)
675}
676
677/// Stream an archive of `dir` (top-level entry `top`) to the client.
678fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
679 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
680 tokio::task::spawn_blocking(move || {
681 let mut sink = ChanWriter::new(tx);
682 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
683 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
684 }
685 // Dropping the sink flushes its buffer and closes the channel.
686 });
687 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
688 axum::body::Body::from_stream(stream)
689}
690
691/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
692/// bridge between the blocking archive builder and the async response body.
693struct ChanWriter {
694 tx: mpsc::Sender<Vec<u8>>,
695 buf: Vec<u8>,
696}
697
698impl ChanWriter {
699 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
700 Self {
701 tx,
702 buf: Vec::with_capacity(64 * 1024),
703 }
704 }
705}
706
707impl io::Write for ChanWriter {
708 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
709 self.buf.extend_from_slice(b);
710 if self.buf.len() >= 64 * 1024 {
711 io::Write::flush(self)?;
712 }
713 Ok(b.len())
714 }
715 fn flush(&mut self) -> io::Result<()> {
716 if !self.buf.is_empty() {
717 let chunk = std::mem::take(&mut self.buf);
718 self.tx
719 .blocking_send(chunk)
720 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
721 }
722 Ok(())
723 }
724}
725
726impl Drop for ChanWriter {
727 fn drop(&mut self) {
728 let _ = io::Write::flush(self);
729 }
730}
731
732// ---------------------------------------------------------------------------
733// POST dispatch: mkdir | rename/move/copy | upload
734// ---------------------------------------------------------------------------
735
736/// POST /api/files/{root_id} — top-level operations (upload into the root
737/// directory). The bare root never targets a specific item, so JSON ops with
738/// a missing folder name are rejected by the individual handlers.
739pub async fn dispatch_root(
740 State(state): State<Arc<AppState>>,
741 auth: AuthUser,
742 path: AxumPath<i64>,
743 headers: axum::http::HeaderMap,
744 req: axum::http::Request<axum::body::Body>,
745) -> Result<Response, ApiError> {
746 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
747}
748
749/// POST /api/files/{root_id}/{*path}
750pub async fn dispatch(
751 State(state): State<Arc<AppState>>,
752 auth: AuthUser,
753 path: AxumPath<(i64, String)>,
754 headers: axum::http::HeaderMap,
755 req: axum::http::Request<axum::body::Body>,
756) -> Result<Response, ApiError> {
757 let (root_id, req_rel) = path.0;
758 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
759}
760
761/// Route one POST to upload, mutation or mkdir.
762///
763/// Upload and mutation are recognized by their content type. mkdir carries no
764/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
765/// an unrecognized content type used to fall through to mkdir, which turned a
766/// typo in a header into a silently created folder.
767async fn dispatch_inner(
768 state: Arc<AppState>,
769 auth: AuthUser,
770 root_id: i64,
771 req_rel: String,
772 headers: axum::http::HeaderMap,
773 req: axum::http::Request<axum::body::Body>,
774) -> Result<Response, ApiError> {
775 let ct = headers
776 .get(header::CONTENT_TYPE)
777 .and_then(|v| v.to_str().ok())
778 .unwrap_or("");
779
780 if ct.starts_with("multipart/form-data") {
781 return upload(state, auth, root_id, req_rel, req).await;
782 }
783 if ct.starts_with("application/json") {
784 let is_exists = action_param(req.uri()).as_deref() == Some(api_types::ACTION_EXISTS);
785 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
786 .await
787 .map_err(|_| {
788 ApiError::localized(
789 StatusCode::BAD_REQUEST,
790 "invalid request body",
791 "err_bad_body",
792 )
793 })?;
794 let bad_body = |_| {
795 ApiError::localized(
796 StatusCode::BAD_REQUEST,
797 "invalid request body",
798 "err_bad_body",
799 )
800 };
801 if is_exists {
802 let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
803 return Ok(exists(state, auth, root_id, req_rel, body)
804 .await?
805 .into_response());
806 }
807 let body: Mutation = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
808 return Ok(mutation(state, auth, root_id, req_rel, body)
809 .await?
810 .into_response());
811 }
812 match action_param(req.uri()).as_deref() {
813 Some(api_types::ACTION_MKDIR) => {
814 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
815 }
816 Some(api_types::ACTION_CREATE_FILE) => {
817 return Ok(create_file(state, auth, root_id, req_rel)
818 .await?
819 .into_response());
820 }
821 _ => {}
822 }
823 Err(ApiError::localized(
824 StatusCode::UNSUPPORTED_MEDIA_TYPE,
825 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
826 "err_bad_post",
827 ))
828}
829
830// ---------------------------------------------------------------------------
831// create file
832// ---------------------------------------------------------------------------
833
834/// Create an empty file in a writable root.
835async fn create_file(
836 state: Arc<AppState>,
837 auth: AuthUser,
838 root_id: i64,
839 req_rel: String,
840) -> Result<Json<OkResp>, ApiError> {
841 let root = require_rw_root(&auth.roots, root_id)?;
842 if req_rel.trim().is_empty() {
843 return Err(ApiError::localized(
844 StatusCode::BAD_REQUEST,
845 "a file name is required",
846 "err_file_name_required",
847 ));
848 }
849 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
850 blocking(move || fs::create_file(&server_root, &root_rel, &rel)).await?;
851 Ok(Json(OkResp {}))
852}
853
854// ---------------------------------------------------------------------------
855// mkdir
856// ---------------------------------------------------------------------------
857
858async fn mkdir(
859 state: Arc<AppState>,
860 auth: AuthUser,
861 root_id: i64,
862 req_rel: String,
863) -> Result<Json<OkResp>, ApiError> {
864 let root = require_rw_root(&auth.roots, root_id)?;
865 if req_rel.trim().is_empty() {
866 return Err(ApiError::localized(
867 StatusCode::BAD_REQUEST,
868 "a folder name is required",
869 "err_folder_name_required",
870 ));
871 }
872 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
873 blocking(move || fs::mkdir(&server_root, &root_rel, &rel)).await?;
874 Ok(Json(OkResp {}))
875}
876
877// ---------------------------------------------------------------------------
878// rename / move / copy
879// ---------------------------------------------------------------------------
880
881async fn mutation(
882 state: Arc<AppState>,
883 auth: AuthUser,
884 root_id: i64,
885 req_rel: String,
886 body: Mutation,
887) -> Result<Json<OkResp>, ApiError> {
888 match body.op {
889 Op::Rename => {
890 let new_name = body
891 .new_name
892 .as_deref()
893 .ok_or_else(|| {
894 ApiError::localized(
895 StatusCode::BAD_REQUEST,
896 "new_name is required",
897 "err_new_name_required",
898 )
899 })?
900 .to_string();
901 let root = require_rw_root(&auth.roots, root_id)?;
902 let (server_root, root_rel, rel, overwrite) = (
903 state.root.clone(),
904 root.path.clone(),
905 req_rel,
906 body.overwrite,
907 );
908 let vacated = blocking(move || {
909 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
910 })
911 .await?;
912 revoke_shares_at(&state, &vacated).await;
913 Ok(Json(OkResp {}))
914 }
915 Op::Move | Op::Copy => {
916 let dst_root_id = body.dst_root_id.ok_or_else(|| {
917 ApiError::localized(
918 StatusCode::BAD_REQUEST,
919 "dst_root_id is required",
920 "err_dst_required",
921 )
922 })?;
923 let dst = body.dst.clone().unwrap_or_default();
924 // Moving or copying out of a folder requires rw there; copying
925 // *from* a read-only root is fine.
926 let op_is_move = body.op == Op::Move;
927 let src_root = if op_is_move {
928 require_rw_root(&auth.roots, root_id)?
929 } else {
930 find_root(&auth.roots, root_id)?
931 };
932 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
933 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
934 state.root.clone(),
935 src_root.path.clone(),
936 dst_root.path.clone(),
937 dst,
938 req_rel,
939 body.overwrite,
940 );
941 let vacated = blocking(move || {
942 if op_is_move {
943 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
944 .map(Some)
945 } else {
946 // A copy frees no path, so it revokes nothing.
947 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
948 .map(|()| None)
949 }
950 })
951 .await?;
952 if let Some(vacated) = vacated {
953 revoke_shares_at(&state, &vacated).await;
954 }
955 Ok(Json(OkResp {}))
956 }
957 }
958}
959
960// ---------------------------------------------------------------------------
961// DELETE
962// ---------------------------------------------------------------------------
963
964pub async fn delete(
965 State(state): State<Arc<AppState>>,
966 auth: AuthUser,
967 path: AxumPath<(i64, String)>,
968) -> Result<Json<DeleteResp>, ApiError> {
969 let (root_id, req_rel) = path.0;
970 let root = require_rw_root(&auth.roots, root_id)?;
971 if req_rel.trim().is_empty() {
972 return Err(ApiError::localized(
973 StatusCode::BAD_REQUEST,
974 "a path inside the folder is required",
975 "err_path_required",
976 ));
977 }
978 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
979 let (is_dir, gone) = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
980 revoke_shares_at(&state, &gone).await;
981 Ok(Json(DeleteResp { is_dir }))
982}
983
984// ---------------------------------------------------------------------------
985// Upload (multipart)
986// ---------------------------------------------------------------------------
987
988async fn upload(
989 state: Arc<AppState>,
990 auth: AuthUser,
991 root_id: i64,
992 req_rel: String,
993 req: axum::http::Request<axum::body::Body>,
994) -> Result<Response, ApiError> {
995 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
996 let boundary = req
997 .headers()
998 .get(header::CONTENT_TYPE)
999 .and_then(|v| v.to_str().ok())
1000 .and_then(parse_boundary)
1001 .ok_or_else(|| {
1002 ApiError::localized(
1003 StatusCode::BAD_REQUEST,
1004 "expected multipart/form-data with a boundary",
1005 "err_bad_multipart",
1006 )
1007 })?;
1008 let overwrite = parse_overwrite(req.uri());
1009
1010 let stream = req.into_body().into_data_stream();
1011 let mut multipart = Multipart::new(stream, boundary);
1012 let mut uploaded: usize = 0;
1013 let mut skipped: Vec<String> = Vec::new();
1014
1015 while let Some(mut field) = multipart.next_field().await.map_err(|_| {
1016 ApiError::localized(
1017 StatusCode::BAD_REQUEST,
1018 "invalid upload data",
1019 "err_bad_upload",
1020 )
1021 })? {
1022 let part_name = field
1023 .name()
1024 .filter(|n| !n.is_empty())
1025 .or_else(|| field.file_name())
1026 .map(decode_cd)
1027 .ok_or_else(|| {
1028 ApiError::localized(
1029 StatusCode::BAD_REQUEST,
1030 "part without a name",
1031 "err_part_no_name",
1032 )
1033 })?;
1034
1035 validate_rel_path(&part_name)?;
1036
1037 let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone());
1038 let target = tokio::task::spawn_blocking(move || upload_target(&r, &b, &rel, true))
1039 .await
1040 .map_err(|_| io::Error::other("join"))?
1041 .map_err(target_error)?
1042 .expect("create_parent resolves every parent");
1043 let (exists, is_dir) = (target.exists, target.is_dir);
1044 let target = target.path;
1045
1046 if exists && !overwrite {
1047 // Drain this part and report it as a conflict at the end.
1048 while let Some(_chunk) = field.chunk().await.map_err(|_| {
1049 ApiError::localized(
1050 StatusCode::BAD_REQUEST,
1051 "invalid upload data",
1052 "err_bad_upload",
1053 )
1054 })? {}
1055 skipped.push(part_name);
1056 continue;
1057 }
1058 if exists && is_dir {
1059 // A folder can never be replaced by a file. Report it like a
1060 // conflict so the client fails this one part, not the request:
1061 // a rejected request makes it retry every other file alone.
1062 while let Some(_chunk) = field.chunk().await.map_err(|_| {
1063 ApiError::localized(
1064 StatusCode::BAD_REQUEST,
1065 "invalid upload data",
1066 "err_bad_upload",
1067 )
1068 })? {}
1069 skipped.push(part_name);
1070 continue;
1071 }
1072
1073 // Stream to a temp file in the same directory, then publish.
1074 let suffix = crate::auth::random_token();
1075 let tmp = Scratch(
1076 target
1077 .parent()
1078 .expect("has parent")
1079 .join(format!(".upload-{suffix}")),
1080 );
1081 let tmp_file = tokio::fs::File::create(&tmp.0).await.map_err(|_| {
1082 ApiError::localized(
1083 StatusCode::INTERNAL_SERVER_ERROR,
1084 "internal error",
1085 "err_internal",
1086 )
1087 })?;
1088 // Buffered: a multipart chunk is often a few kilobytes, and each
1089 // unbuffered write would be its own syscall.
1090 let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file);
1091 let write_failed = loop {
1092 match field.chunk().await.map_err(|_| {
1093 ApiError::localized(
1094 StatusCode::BAD_REQUEST,
1095 "invalid upload data",
1096 "err_bad_upload",
1097 )
1098 }) {
1099 Ok(Some(chunk)) => {
1100 if let Err(e) = tmp_file.write_all(&chunk).await {
1101 tracing::warn!(error = %e, "write failed during upload");
1102 break true;
1103 }
1104 }
1105 Ok(None) => break tmp_file.flush().await.is_err(),
1106 Err(e) => return Err(e),
1107 }
1108 };
1109 if write_failed {
1110 return Err(ApiError::localized(
1111 StatusCode::INTERNAL_SERVER_ERROR,
1112 "could not save the file",
1113 "err_save_failed",
1114 ));
1115 }
1116 let tmp2 = tmp.0.clone();
1117 let target2 = target.clone();
1118 // Flatten both errors: the outer `Err` is a panicking or shut-down task,
1119 // the inner one is `publish` refusing. Either way nothing was published.
1120 let published = tokio::task::spawn_blocking(move || publish(&tmp2, &target2, overwrite))
1121 .await
1122 .map_err(io::Error::other)
1123 .and_then(|r| r);
1124 match published {
1125 Ok(Published::Written) => {}
1126 // The target appeared while the body streamed in. The drop
1127 // guard removes the scratch file.
1128 Ok(Published::Exists) => {
1129 skipped.push(part_name);
1130 continue;
1131 }
1132 Err(e) => {
1133 tracing::warn!(error = %e, path = %target.display(), "publish failed during upload");
1134 return Err(ApiError::localized(
1135 StatusCode::INTERNAL_SERVER_ERROR,
1136 "internal error",
1137 "err_internal",
1138 ));
1139 }
1140 }
1141 tmp.disarm();
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
1163/// The rw root and the upload directory. `root_abs` is the containment
1164/// boundary (a symlink may legitimately point elsewhere inside it), `base`
1165/// the directory the request names.
1166async fn upload_base(
1167 state: &AppState,
1168 auth: &AuthUser,
1169 root_id: i64,
1170 req_rel: String,
1171) -> Result<(PathBuf, PathBuf), ApiError> {
1172 let root = require_rw_root(&auth.roots, root_id)?;
1173 let (server_root, root_rel) = (state.root.clone(), root.path.clone());
1174 blocking(move || {
1175 Ok::<_, FsError>((
1176 fs::resolve_root(&server_root, &root_rel)?,
1177 fs::resolve_dir(&server_root, &root_rel, &req_rel)?,
1178 ))
1179 })
1180 .await
1181}
1182
1183/// One upload target on disk. `path` has a canonical parent that is inside
1184/// the root.
1185struct Target {
1186 path: PathBuf,
1187 exists: bool,
1188 is_dir: bool,
1189}
1190
1191/// Resolve `rel` under `base` for an upload. The nearest existing ancestor
1192/// and the final parent are both checked against `root_abs`, so nothing is
1193/// created or written outside the root even through a symlinked directory.
1194/// With `create_parent` missing directories are created. Without it a
1195/// missing parent returns `None`: the target cannot exist.
1196///
1197/// `exists` uses `symlink_metadata`, so a dangling symlink counts as
1198/// existing and is not silently replaced.
1199fn upload_target(
1200 root_abs: &Path,
1201 base: &Path,
1202 rel: &str,
1203 create_parent: bool,
1204) -> io::Result<Option<Target>> {
1205 let escape = || io::Error::other("upload parent escapes the root");
1206 let full = base.join(rel);
1207 let (Some(parent), Some(name)) = (
1208 full.parent().filter(|p| !p.as_os_str().is_empty()),
1209 full.file_name(),
1210 ) else {
1211 return Err(io::Error::new(
1212 io::ErrorKind::InvalidInput,
1213 "invalid part name",
1214 ));
1215 };
1216 let mut existing = parent;
1217 while !existing.exists() {
1218 existing = existing.parent().ok_or_else(escape)?;
1219 }
1220 if !fs::is_within_or_eq(root_abs, &existing.canonicalize()?) {
1221 return Err(escape());
1222 }
1223 if !parent.is_dir() {
1224 if !create_parent {
1225 return Ok(None);
1226 }
1227 std::fs::create_dir_all(parent)?;
1228 }
1229 let canon = parent.canonicalize()?;
1230 if !fs::is_within_or_eq(root_abs, &canon) {
1231 return Err(escape());
1232 }
1233 let path = canon.join(name);
1234 Ok(Some(Target {
1235 exists: std::fs::symlink_metadata(&path).is_ok(),
1236 is_dir: std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
1237 path,
1238 }))
1239}
1240
1241/// Map an [`upload_target`] error to the API error: a malformed name is a
1242/// 400, everything else (escape, io) a 403 as before.
1243fn target_error(e: io::Error) -> ApiError {
1244 if e.kind() == io::ErrorKind::InvalidInput {
1245 return ApiError::localized(
1246 StatusCode::BAD_REQUEST,
1247 "invalid part name",
1248 "err_bad_part_name",
1249 );
1250 }
1251 tracing::warn!(error = %e, "upload parent rejected");
1252 ApiError::localized(
1253 StatusCode::FORBIDDEN,
1254 "invalid file path in upload",
1255 "err_bad_upload_path",
1256 )
1257}
1258
1259/// `POST /api/files/{root_id}/{*path}?action=exists` — which upload targets
1260/// already exist. Read-only: no directory is created. The client asks this
1261/// before uploading so the overwrite question comes before the transfer.
1262async fn exists(
1263 state: Arc<AppState>,
1264 auth: AuthUser,
1265 root_id: i64,
1266 req_rel: String,
1267 body: ExistsReq,
1268) -> Result<Json<ExistsResp>, ApiError> {
1269 if body.paths.len() > 10_000 {
1270 return Err(ApiError::localized(
1271 StatusCode::BAD_REQUEST,
1272 "too many paths",
1273 "err_too_many_paths",
1274 ));
1275 }
1276 for p in &body.paths {
1277 validate_rel_path(p)?;
1278 }
1279 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
1280 let existing = tokio::task::spawn_blocking(move || {
1281 let mut out = Vec::new();
1282 for path in body.paths {
1283 if let Some(t) = upload_target(&root_abs, &base, &path, false)?
1284 && t.exists
1285 {
1286 out.push(Existing {
1287 path,
1288 is_dir: t.is_dir,
1289 });
1290 }
1291 }
1292 Ok::<_, io::Error>(out)
1293 })
1294 .await
1295 .map_err(|_| io::Error::other("join"))?
1296 .map_err(target_error)?;
1297 Ok(Json(ExistsResp { existing }))
1298}
1299
1300/// Outcome of [`publish`].
1301enum Published {
1302 Written,
1303 /// The target exists and `overwrite` was off. Nothing was replaced.
1304 Exists,
1305}
1306
1307/// Move the finished scratch file to `target`. With `overwrite`, `rename`
1308/// replaces whatever is there. Without it the target must not exist at the
1309/// moment of publishing: `hard_link` fails with `AlreadyExists` atomically,
1310/// which closes the window between the pre-upload stat and the publish.
1311/// On success `tmp` is gone in both cases.
1312fn publish(tmp: &Path, target: &Path, overwrite: bool) -> io::Result<Published> {
1313 if overwrite {
1314 std::fs::rename(tmp, target)?;
1315 return Ok(Published::Written);
1316 }
1317 match std::fs::hard_link(tmp, target) {
1318 Ok(()) => {
1319 std::fs::remove_file(tmp)?;
1320 Ok(Published::Written)
1321 }
1322 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(Published::Exists),
1323 Err(e) if link_unsupported(&e) => {
1324 tracing::warn!(error = %e, "hard links unsupported here, falling back to stat + rename");
1325 // ponytail: stat-then-rename leaves a microsecond window in which
1326 // a file created by someone else is replaced. Closing it needs
1327 // renameat2(RENAME_NOREPLACE) through libc.
1328 if std::fs::symlink_metadata(target).is_ok() {
1329 return Ok(Published::Exists);
1330 }
1331 std::fs::rename(tmp, target)?;
1332 Ok(Published::Written)
1333 }
1334 Err(e) => Err(e),
1335 }
1336}
1337
1338/// The filesystem refuses hard links: EPERM (some network mounts, restricted
1339/// namespaces), ENOTSUP, or EXDEV. Only EXDEV needs its raw code; the other
1340/// two map to an `ErrorKind`.
1341fn link_unsupported(e: &io::Error) -> bool {
1342 matches!(
1343 e.kind(),
1344 io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
1345 ) || e.raw_os_error() == Some(18)
1346}
1347
1348/// Undo the client's `Content-Disposition` escaping (WHATWG form-data): the
1349/// three characters that cannot appear raw in a quoted header value. `multer`
1350/// does not do this itself.
1351fn decode_cd(s: &str) -> String {
1352 s.replace("%22", "\"")
1353 .replace("%0D", "\r")
1354 .replace("%0A", "\n")
1355}
1356
1357/// The `.upload-<token>` scratch file of one in-flight upload part. Dropping it
1358/// removes the file, which covers the paths no `return` can see, above all the
1359/// request future being dropped when the client closes the connection. A leaked
1360/// scratch file is never named again and shows up in listings, which include
1361/// hidden entries on purpose. [`Scratch::disarm`] after a publish skips the
1362/// unlink of a path that is now the uploaded file.
1363struct Scratch(PathBuf);
1364
1365impl Scratch {
1366 /// The scratch file is now the uploaded file: leave it alone.
1367 fn disarm(self) {
1368 std::mem::forget(self);
1369 }
1370}
1371
1372impl Drop for Scratch {
1373 fn drop(&mut self) {
1374 // Plain blocking unlink: `Drop` can run during runtime shutdown, where
1375 // `tokio::spawn` panics. One unlink cannot block meaningfully.
1376 if let Err(e) = std::fs::remove_file(&self.0)
1377 && e.kind() != io::ErrorKind::NotFound
1378 {
1379 tracing::warn!(error = %e, path = %self.0.display(), "could not remove upload scratch file");
1380 }
1381 }
1382}
1383
1384fn parse_boundary(content_type: &str) -> Option<String> {
1385 content_type
1386 .split(';')
1387 .map(|s| s.trim())
1388 .find_map(|s| s.strip_prefix("boundary="))
1389 .map(|b| b.trim_matches('"').to_string())
1390 .filter(|b| !b.is_empty())
1391}
1392
1393/// Read one query parameter from the request URI.
1394fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
1395 uri.query()?.split('&').find_map(|kv| {
1396 let (k, v) = kv.split_once('=')?;
1397 (k == key).then(|| v.to_string())
1398 })
1399}
1400
1401fn action_param(uri: &axum::http::Uri) -> Option<String> {
1402 query_param(uri, P_ACTION)
1403}
1404
1405fn parse_overwrite(uri: &axum::http::Uri) -> bool {
1406 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
1407}
1408
1409fn validate_rel_path(name: &str) -> Result<(), ApiError> {
1410 for c in std::path::Path::new(name).components() {
1411 match c {
1412 Component::Normal(_) => {}
1413 _ => {
1414 return Err(ApiError::localized(
1415 StatusCode::BAD_REQUEST,
1416 "invalid file path in upload",
1417 "err_bad_upload_path",
1418 ));
1419 }
1420 }
1421 }
1422 Ok(())
1423}
1424
1425// ---------------------------------------------------------------------------
1426// Helpers
1427// ---------------------------------------------------------------------------
1428
1429/// Drop every share that named `abs` or anything under it.
1430///
1431/// Called after a delete, a rename, or a move: each one frees a path, and a
1432/// share stores a path, not a file identity. Without this, a *new* item that
1433/// later lands on the freed path would inherit the old link's audience.
1434///
1435/// Only covers changes made through this API. A file moved out from under the
1436/// server (over SSH, say) leaves its shares in place, still pointing at a
1437/// path. Closing that needs inode pinning, which breaks across a restore from
1438/// backup, so it is deliberately not done.
1439///
1440/// Best-effort: the file operation has already succeeded by the time this
1441/// runs, so a database error must not turn it into a 500. The client would
1442/// read that as "the delete failed" and retry, and the retry would 404. The
1443/// failure is logged at `error` instead, and leaves a share pointing at a
1444/// path that no longer holds what it did.
1445pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
1446 let target = target_rel(state, abs);
1447 match state.db.revoke_shares_at(&target).await {
1448 Ok(0) => {}
1449 Ok(n) => tracing::info!(target = %target, revoked = n, "shares revoked: path is gone"),
1450 Err(e) => {
1451 tracing::error!(error = %e, target = %target, "could not revoke shares on a freed path")
1452 }
1453 }
1454}
1455
1456fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1457 roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
1458 ApiError::localized(
1459 StatusCode::FORBIDDEN,
1460 "no such folder",
1461 "err_no_such_folder",
1462 )
1463 })
1464}
1465
1466fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1467 let root = find_root(roots, root_id)?;
1468 if !root.mode.is_writable() {
1469 return Err(ApiError::localized(
1470 StatusCode::FORBIDDEN,
1471 "read-only folder",
1472 "err_read_only_folder",
1473 ));
1474 }
1475 Ok(root)
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use api_types::{
1482 ACTION_DOWNLOAD, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_SORT,
1483 };
1484 use axum::http::Uri;
1485
1486 /// The publish step must never replace a file that appeared after the
1487 /// pre-upload stat unless `overwrite` is on.
1488 #[test]
1489 fn publish_refuses_an_existing_target_without_overwrite() {
1490 let dir = tempfile::tempdir().unwrap();
1491 let tmp = dir.path().join(".upload-1");
1492 let target = dir.path().join("a.txt");
1493
1494 std::fs::write(&tmp, b"new").unwrap();
1495 assert!(matches!(
1496 publish(&tmp, &target, false).unwrap(),
1497 Published::Written
1498 ));
1499 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1500 assert!(!tmp.exists(), "scratch file must be gone after publish");
1501
1502 // The target exists now: no overwrite → untouched.
1503 std::fs::write(&tmp, b"racer").unwrap();
1504 assert!(matches!(
1505 publish(&tmp, &target, false).unwrap(),
1506 Published::Exists
1507 ));
1508 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1509 assert!(
1510 tmp.exists(),
1511 "the caller's drop guard removes the scratch file"
1512 );
1513
1514 // With overwrite the target is replaced.
1515 assert!(matches!(
1516 publish(&tmp, &target, true).unwrap(),
1517 Published::Written
1518 ));
1519 assert_eq!(std::fs::read(&target).unwrap(), b"racer");
1520 assert!(!tmp.exists());
1521 }
1522
1523 /// A rename of a `P_*` constant without the matching field
1524 /// rename would silently stop the server from reading the parameter the
1525 /// client sends. This builds the query string from the constants and
1526 /// runs the real extractor over it.
1527 #[test]
1528 fn query_fields_are_the_shared_constants() {
1529 let uri: Uri = format!("/f/1/a.txt?{P_ACTION}={ACTION_DOWNLOAD}&{P_FORMAT}=zip")
1530 .parse()
1531 .unwrap();
1532 let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
1533 assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD));
1534 assert_eq!(q.format.as_deref(), Some("zip"));
1535 let list_uri: Uri =
1536 format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true&{P_AROUND}=a.txt")
1537 .parse()
1538 .unwrap();
1539 let q: FileQuery = AxumQuery::try_from_uri(&list_uri).unwrap().0;
1540 assert_eq!(
1541 (
1542 q.sort,
1543 q.desc,
1544 q.offset,
1545 q.limit,
1546 q.dirs,
1547 q.around.as_deref()
1548 ),
1549 (SortKey::Size, true, 5, Some(10), true, Some("a.txt"))
1550 );
1551
1552 // The same constants drive the hand-rolled readers on the POST path.
1553 assert_eq!(action_param(&uri).as_deref(), Some(ACTION_DOWNLOAD));
1554 let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=true").parse().unwrap();
1555 assert!(parse_overwrite(&uri));
1556 }
1557}
1558