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