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