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 (start, end) = match parse_range(range, size) {
533 Some(Some(r)) => r,
534 Some(None) => {
535 return Response::builder()
536 .status(StatusCode::RANGE_NOT_SATISFIABLE)
537 .header(header::CONTENT_RANGE, format!("bytes */{size}"))
538 .body(axum::body::Body::empty())
539 .map_err(|e| {
540 ApiError::new(
541 StatusCode::INTERNAL_SERVER_ERROR,
542 format!("bad response: {e}"),
543 )
544 });
545 }
546 None => (0, size),
547 };
548 let partial = (start, end) != (0, size);
549 let body = stream_file(full.to_path_buf(), start, end);
550 let mut res = Response::builder()
551 .status(if partial {
552 StatusCode::PARTIAL_CONTENT
553 } else {
554 StatusCode::OK
555 })
556 .header(header::CONTENT_DISPOSITION, disp)
557 .header(header::ACCEPT_RANGES, "bytes")
558 .header(header::CONTENT_LENGTH, end - start)
559 // Always sent, validator or not: with no directive a cache may apply
560 // heuristic freshness to a response that depends on who asked.
561 .header(header::CACHE_CONTROL, FILE_CACHE);
562 if let Some(lm) = last_modified {
563 // The validator the `no-cache` above revalidates against.
564 res = res.header(header::LAST_MODIFIED, lm);
565 }
566 if partial {
567 res = res.header(
568 header::CONTENT_RANGE,
569 format!("bytes {start}-{}/{size}", end - 1),
570 );
571 }
572 // A file the browser would parse as a document (HTML/SVG/XML) is served
573 // under the sandboxed policy, so it can render as a page without being
574 // able to act as the app. Derived from the same `mime` we declare.
575 // Non-scriptable inline files (PDF, …) are frameable by the app itself,
576 // for the preview modal.
577 if crate::api::is_scriptable_mime(&mime) {
578 res = res.header("content-security-policy", crate::api::FILE_CSP);
579 } else if inline {
580 res = res
581 .header("content-security-policy", crate::api::INLINE_CSP)
582 .header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
583 }
584 res.header(header::CONTENT_TYPE, mime)
585 .body(body)
586 .map_err(|e| {
587 ApiError::new(
588 StatusCode::INTERNAL_SERVER_ERROR,
589 format!("bad response: {e}"),
590 )
591 })
592}
593
594/// Parse a `Range` header against `size`. `None` = serve the whole file,
595/// `Some(None)` = unsatisfiable, `Some(Some((start, end)))` = half-open range.
596fn parse_range(range: Option<&str>, size: u64) -> Option<Option<(u64, u64)>> {
597 let spec = range?.strip_prefix("bytes=")?;
598 // ponytail: one range only; multipart/byteranges is not worth it here.
599 let (a, b) = spec.split_once('-')?;
600 let (start, end) = match (a.trim().parse::<u64>().ok(), b.trim().parse::<u64>().ok()) {
601 (Some(s), Some(e)) => (s, e.saturating_add(1).min(size)),
602 (Some(s), None) if b.trim().is_empty() => (s, size),
603 // Suffix form: the last N bytes.
604 (None, Some(n)) if a.trim().is_empty() => (size.saturating_sub(n), size),
605 _ => return None,
606 };
607 if start >= size || start >= end {
608 return Some(None);
609 }
610 Some(Some((start, end)))
611}
612
613/// Stream `path[start..end)` to the client in chunks (blocking reader → channel).
614fn stream_file(path: std::path::PathBuf, start: u64, end: u64) -> axum::body::Body {
615 use std::io::Seek;
616 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
617 tokio::task::spawn_blocking(move || {
618 let mut f = match std::fs::File::open(&path) {
619 Ok(f) => f,
620 Err(e) => {
621 tracing::warn!(error = %e, path = %path.display(), "download open failed");
622 return;
623 }
624 };
625 if start > 0 && f.seek(io::SeekFrom::Start(start)).is_err() {
626 return;
627 }
628 let mut left = end - start;
629 let mut buf = vec![0u8; 256 * 1024];
630 while left > 0 {
631 let want = buf.len().min(left as usize);
632 match f.read(&mut buf[..want]) {
633 Ok(0) => break,
634 Ok(n) => {
635 left -= n as u64;
636 // Client gone → stop producing.
637 if tx.blocking_send(buf[..n].to_vec()).is_err() {
638 break;
639 }
640 }
641 Err(e) => {
642 tracing::warn!(error = %e, path = %path.display(), "download read failed");
643 break;
644 }
645 }
646 }
647 });
648 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
649 axum::body::Body::from_stream(stream)
650}
651
652/// Stream an archive of `dir` (top-level entry `top`) to the client.
653fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
654 let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
655 tokio::task::spawn_blocking(move || {
656 let mut sink = ChanWriter::new(tx);
657 if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
658 tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
659 }
660 // Dropping the sink flushes its buffer and closes the channel.
661 });
662 let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
663 axum::body::Body::from_stream(stream)
664}
665
666/// A `Write` that buffers chunks and forwards them over an mpsc channel — the
667/// bridge between the blocking archive builder and the async response body.
668struct ChanWriter {
669 tx: mpsc::Sender<Vec<u8>>,
670 buf: Vec<u8>,
671}
672
673impl ChanWriter {
674 fn new(tx: mpsc::Sender<Vec<u8>>) -> Self {
675 Self {
676 tx,
677 buf: Vec::with_capacity(64 * 1024),
678 }
679 }
680}
681
682impl io::Write for ChanWriter {
683 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
684 self.buf.extend_from_slice(b);
685 if self.buf.len() >= 64 * 1024 {
686 io::Write::flush(self)?;
687 }
688 Ok(b.len())
689 }
690 fn flush(&mut self) -> io::Result<()> {
691 if !self.buf.is_empty() {
692 let chunk = std::mem::take(&mut self.buf);
693 self.tx
694 .blocking_send(chunk)
695 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?;
696 }
697 Ok(())
698 }
699}
700
701impl Drop for ChanWriter {
702 fn drop(&mut self) {
703 let _ = io::Write::flush(self);
704 }
705}
706
707// ---------------------------------------------------------------------------
708// POST dispatch: mkdir | rename/move/copy | upload
709// ---------------------------------------------------------------------------
710
711/// POST /api/files/{root_id} — top-level operations (upload into the root
712/// directory). The bare root never targets a specific item, so JSON ops with
713/// a missing folder name are rejected by the individual handlers.
714pub async fn dispatch_root(
715 State(state): State<Arc<AppState>>,
716 auth: AuthUser,
717 path: AxumPath<i64>,
718 headers: axum::http::HeaderMap,
719 req: axum::http::Request<axum::body::Body>,
720) -> Result<Response, ApiError> {
721 dispatch_inner(state, auth, path.0, String::new(), headers, req).await
722}
723
724/// POST /api/files/{root_id}/{*path}
725pub async fn dispatch(
726 State(state): State<Arc<AppState>>,
727 auth: AuthUser,
728 path: AxumPath<(i64, String)>,
729 headers: axum::http::HeaderMap,
730 req: axum::http::Request<axum::body::Body>,
731) -> Result<Response, ApiError> {
732 let (root_id, req_rel) = path.0;
733 dispatch_inner(state, auth, root_id, req_rel, headers, req).await
734}
735
736/// Route one POST to upload, mutation or mkdir.
737///
738/// Upload and mutation are recognized by their content type. mkdir carries no
739/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
740/// an unrecognized content type used to fall through to mkdir, which turned a
741/// typo in a header into a silently created folder.
742async fn dispatch_inner(
743 state: Arc<AppState>,
744 auth: AuthUser,
745 root_id: i64,
746 req_rel: String,
747 headers: axum::http::HeaderMap,
748 req: axum::http::Request<axum::body::Body>,
749) -> Result<Response, ApiError> {
750 let ct = headers
751 .get(header::CONTENT_TYPE)
752 .and_then(|v| v.to_str().ok())
753 .unwrap_or("");
754
755 if ct.starts_with("multipart/form-data") {
756 return upload(state, auth, root_id, req_rel, req).await;
757 }
758 if ct.starts_with("application/json") {
759 let is_exists = action_param(req.uri()).as_deref() == Some(api_types::ACTION_EXISTS);
760 let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
761 .await
762 .map_err(|_| {
763 ApiError::localized(
764 StatusCode::BAD_REQUEST,
765 "invalid request body",
766 "err_bad_body",
767 )
768 })?;
769 let bad_body = |_| {
770 ApiError::localized(
771 StatusCode::BAD_REQUEST,
772 "invalid request body",
773 "err_bad_body",
774 )
775 };
776 if is_exists {
777 let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
778 return Ok(exists(state, auth, root_id, req_rel, body)
779 .await?
780 .into_response());
781 }
782 let body: Mutation = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
783 return Ok(mutation(state, auth, root_id, req_rel, body)
784 .await?
785 .into_response());
786 }
787 match action_param(req.uri()).as_deref() {
788 Some(api_types::ACTION_MKDIR) => {
789 return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
790 }
791 Some(api_types::ACTION_CREATE_FILE) => {
792 return Ok(create_file(state, auth, root_id, req_rel)
793 .await?
794 .into_response());
795 }
796 _ => {}
797 }
798 Err(ApiError::localized(
799 StatusCode::UNSUPPORTED_MEDIA_TYPE,
800 "POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
801 "err_bad_post",
802 ))
803}
804
805// ---------------------------------------------------------------------------
806// create file
807// ---------------------------------------------------------------------------
808
809/// Create an empty file in a writable root.
810async fn create_file(
811 state: Arc<AppState>,
812 auth: AuthUser,
813 root_id: i64,
814 req_rel: String,
815) -> Result<Json<OkResp>, ApiError> {
816 let root = require_rw_root(&auth.roots, root_id)?;
817 if req_rel.trim().is_empty() {
818 return Err(ApiError::localized(
819 StatusCode::BAD_REQUEST,
820 "a file name is required",
821 "err_file_name_required",
822 ));
823 }
824 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
825 blocking(move || fs::create_file(&server_root, &root_rel, &rel)).await?;
826 Ok(Json(OkResp {}))
827}
828
829// ---------------------------------------------------------------------------
830// mkdir
831// ---------------------------------------------------------------------------
832
833async fn mkdir(
834 state: Arc<AppState>,
835 auth: AuthUser,
836 root_id: i64,
837 req_rel: String,
838) -> Result<Json<OkResp>, ApiError> {
839 let root = require_rw_root(&auth.roots, root_id)?;
840 if req_rel.trim().is_empty() {
841 return Err(ApiError::localized(
842 StatusCode::BAD_REQUEST,
843 "a folder name is required",
844 "err_folder_name_required",
845 ));
846 }
847 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
848 blocking(move || fs::mkdir(&server_root, &root_rel, &rel)).await?;
849 Ok(Json(OkResp {}))
850}
851
852// ---------------------------------------------------------------------------
853// rename / move / copy
854// ---------------------------------------------------------------------------
855
856async fn mutation(
857 state: Arc<AppState>,
858 auth: AuthUser,
859 root_id: i64,
860 req_rel: String,
861 body: Mutation,
862) -> Result<Json<OkResp>, ApiError> {
863 match body.op {
864 Op::Rename => {
865 let new_name = body
866 .new_name
867 .as_deref()
868 .ok_or_else(|| {
869 ApiError::localized(
870 StatusCode::BAD_REQUEST,
871 "new_name is required",
872 "err_new_name_required",
873 )
874 })?
875 .to_string();
876 let root = require_rw_root(&auth.roots, root_id)?;
877 let (server_root, root_rel, rel, overwrite) = (
878 state.root.clone(),
879 root.path.clone(),
880 req_rel,
881 body.overwrite,
882 );
883 let vacated = blocking(move || {
884 fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
885 })
886 .await?;
887 revoke_shares_at(&state, &vacated).await;
888 Ok(Json(OkResp {}))
889 }
890 Op::Move | Op::Copy => {
891 let dst_root_id = body.dst_root_id.ok_or_else(|| {
892 ApiError::localized(
893 StatusCode::BAD_REQUEST,
894 "dst_root_id is required",
895 "err_dst_required",
896 )
897 })?;
898 let dst = body.dst.clone().unwrap_or_default();
899 // Moving or copying out of a folder requires rw there; copying
900 // *from* a read-only root is fine.
901 let op_is_move = body.op == Op::Move;
902 let src_root = if op_is_move {
903 require_rw_root(&auth.roots, root_id)?
904 } else {
905 find_root(&auth.roots, root_id)?
906 };
907 let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
908 let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
909 state.root.clone(),
910 src_root.path.clone(),
911 dst_root.path.clone(),
912 dst,
913 req_rel,
914 body.overwrite,
915 );
916 let vacated = blocking(move || {
917 if op_is_move {
918 fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
919 .map(Some)
920 } else {
921 // A copy frees no path, so it revokes nothing.
922 fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
923 .map(|()| None)
924 }
925 })
926 .await?;
927 if let Some(vacated) = vacated {
928 revoke_shares_at(&state, &vacated).await;
929 }
930 Ok(Json(OkResp {}))
931 }
932 }
933}
934
935// ---------------------------------------------------------------------------
936// DELETE
937// ---------------------------------------------------------------------------
938
939pub async fn delete(
940 State(state): State<Arc<AppState>>,
941 auth: AuthUser,
942 path: AxumPath<(i64, String)>,
943) -> Result<Json<DeleteResp>, ApiError> {
944 let (root_id, req_rel) = path.0;
945 let root = require_rw_root(&auth.roots, root_id)?;
946 if req_rel.trim().is_empty() {
947 return Err(ApiError::localized(
948 StatusCode::BAD_REQUEST,
949 "a path inside the folder is required",
950 "err_path_required",
951 ));
952 }
953 let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
954 let (is_dir, gone) = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
955 revoke_shares_at(&state, &gone).await;
956 Ok(Json(DeleteResp { is_dir }))
957}
958
959// ---------------------------------------------------------------------------
960// Upload (multipart)
961// ---------------------------------------------------------------------------
962
963async fn upload(
964 state: Arc<AppState>,
965 auth: AuthUser,
966 root_id: i64,
967 req_rel: String,
968 req: axum::http::Request<axum::body::Body>,
969) -> Result<Response, ApiError> {
970 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
971 let boundary = req
972 .headers()
973 .get(header::CONTENT_TYPE)
974 .and_then(|v| v.to_str().ok())
975 .and_then(parse_boundary)
976 .ok_or_else(|| {
977 ApiError::localized(
978 StatusCode::BAD_REQUEST,
979 "expected multipart/form-data with a boundary",
980 "err_bad_multipart",
981 )
982 })?;
983 let overwrite = parse_overwrite(req.uri());
984
985 let stream = req.into_body().into_data_stream();
986 let mut multipart = Multipart::new(stream, boundary);
987 let mut uploaded: usize = 0;
988 let mut skipped: Vec<String> = Vec::new();
989
990 while let Some(mut field) = multipart.next_field().await.map_err(|_| {
991 ApiError::localized(
992 StatusCode::BAD_REQUEST,
993 "invalid upload data",
994 "err_bad_upload",
995 )
996 })? {
997 let part_name = field
998 .name()
999 .filter(|n| !n.is_empty())
1000 .or_else(|| field.file_name())
1001 .map(decode_cd)
1002 .ok_or_else(|| {
1003 ApiError::localized(
1004 StatusCode::BAD_REQUEST,
1005 "part without a name",
1006 "err_part_no_name",
1007 )
1008 })?;
1009
1010 validate_rel_path(&part_name)?;
1011
1012 let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone());
1013 let target = tokio::task::spawn_blocking(move || upload_target(&r, &b, &rel, true))
1014 .await
1015 .map_err(|_| io::Error::other("join"))?
1016 .map_err(target_error)?
1017 .expect("create_parent resolves every parent");
1018 let (exists, is_dir) = (target.exists, target.is_dir);
1019 let target = target.path;
1020
1021 if exists && !overwrite {
1022 // Drain this part and report it as a conflict at the end.
1023 while let Some(_chunk) = field.chunk().await.map_err(|_| {
1024 ApiError::localized(
1025 StatusCode::BAD_REQUEST,
1026 "invalid upload data",
1027 "err_bad_upload",
1028 )
1029 })? {}
1030 skipped.push(part_name);
1031 continue;
1032 }
1033 if exists && is_dir {
1034 // A folder can never be replaced by a file. Report it like a
1035 // conflict so the client fails this one part, not the request:
1036 // a rejected request makes it retry every other file alone.
1037 while let Some(_chunk) = field.chunk().await.map_err(|_| {
1038 ApiError::localized(
1039 StatusCode::BAD_REQUEST,
1040 "invalid upload data",
1041 "err_bad_upload",
1042 )
1043 })? {}
1044 skipped.push(part_name);
1045 continue;
1046 }
1047
1048 // Stream to a temp file in the same directory, then publish.
1049 let suffix = crate::auth::random_token();
1050 let tmp = Scratch(
1051 target
1052 .parent()
1053 .expect("has parent")
1054 .join(format!(".upload-{suffix}")),
1055 );
1056 let tmp_file = tokio::fs::File::create(&tmp.0).await.map_err(|_| {
1057 ApiError::localized(
1058 StatusCode::INTERNAL_SERVER_ERROR,
1059 "internal error",
1060 "err_internal",
1061 )
1062 })?;
1063 // Buffered: a multipart chunk is often a few kilobytes, and each
1064 // unbuffered write would be its own syscall.
1065 let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file);
1066 let write_failed = loop {
1067 match field.chunk().await.map_err(|_| {
1068 ApiError::localized(
1069 StatusCode::BAD_REQUEST,
1070 "invalid upload data",
1071 "err_bad_upload",
1072 )
1073 }) {
1074 Ok(Some(chunk)) => {
1075 if let Err(e) = tmp_file.write_all(&chunk).await {
1076 tracing::warn!(error = %e, "write failed during upload");
1077 break true;
1078 }
1079 }
1080 Ok(None) => break tmp_file.flush().await.is_err(),
1081 Err(e) => return Err(e),
1082 }
1083 };
1084 if write_failed {
1085 return Err(ApiError::localized(
1086 StatusCode::INTERNAL_SERVER_ERROR,
1087 "could not save the file",
1088 "err_save_failed",
1089 ));
1090 }
1091 let tmp2 = tmp.0.clone();
1092 let target2 = target.clone();
1093 // Flatten both errors: the outer `Err` is a panicking or shut-down task,
1094 // the inner one is `publish` refusing. Either way nothing was published.
1095 let published = tokio::task::spawn_blocking(move || publish(&tmp2, &target2, overwrite))
1096 .await
1097 .map_err(io::Error::other)
1098 .and_then(|r| r);
1099 match published {
1100 Ok(Published::Written) => {}
1101 // The target appeared while the body streamed in. The drop
1102 // guard removes the scratch file.
1103 Ok(Published::Exists) => {
1104 skipped.push(part_name);
1105 continue;
1106 }
1107 Err(e) => {
1108 tracing::warn!(error = %e, path = %target.display(), "publish failed during upload");
1109 return Err(ApiError::localized(
1110 StatusCode::INTERNAL_SERVER_ERROR,
1111 "internal error",
1112 "err_internal",
1113 ));
1114 }
1115 }
1116 tmp.disarm();
1117 uploaded += 1;
1118 }
1119
1120 if uploaded == 0 && skipped.is_empty() {
1121 return Err(ApiError::localized(
1122 StatusCode::BAD_REQUEST,
1123 "no files were uploaded",
1124 "err_no_files_uploaded",
1125 ));
1126 }
1127 if !skipped.is_empty() {
1128 return Err(ApiError::localized(
1129 StatusCode::CONFLICT,
1130 "some files already exist",
1131 "err_files_exist",
1132 )
1133 .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
1134 }
1135 Ok(Json(UploadResp { uploaded }).into_response())
1136}
1137
1138/// The rw root and the upload directory. `root_abs` is the containment
1139/// boundary (a symlink may legitimately point elsewhere inside it), `base`
1140/// the directory the request names.
1141async fn upload_base(
1142 state: &AppState,
1143 auth: &AuthUser,
1144 root_id: i64,
1145 req_rel: String,
1146) -> Result<(PathBuf, PathBuf), ApiError> {
1147 let root = require_rw_root(&auth.roots, root_id)?;
1148 let (server_root, root_rel) = (state.root.clone(), root.path.clone());
1149 blocking(move || {
1150 Ok::<_, FsError>((
1151 fs::resolve_root(&server_root, &root_rel)?,
1152 fs::resolve_dir(&server_root, &root_rel, &req_rel)?,
1153 ))
1154 })
1155 .await
1156}
1157
1158/// One upload target on disk. `path` has a canonical parent that is inside
1159/// the root.
1160struct Target {
1161 path: PathBuf,
1162 exists: bool,
1163 is_dir: bool,
1164}
1165
1166/// Resolve `rel` under `base` for an upload. The nearest existing ancestor
1167/// and the final parent are both checked against `root_abs`, so nothing is
1168/// created or written outside the root even through a symlinked directory.
1169/// With `create_parent` missing directories are created. Without it a
1170/// missing parent returns `None`: the target cannot exist.
1171///
1172/// `exists` uses `symlink_metadata`, so a dangling symlink counts as
1173/// existing and is not silently replaced.
1174fn upload_target(
1175 root_abs: &Path,
1176 base: &Path,
1177 rel: &str,
1178 create_parent: bool,
1179) -> io::Result<Option<Target>> {
1180 let escape = || io::Error::other("upload parent escapes the root");
1181 let full = base.join(rel);
1182 let (Some(parent), Some(name)) = (
1183 full.parent().filter(|p| !p.as_os_str().is_empty()),
1184 full.file_name(),
1185 ) else {
1186 return Err(io::Error::new(
1187 io::ErrorKind::InvalidInput,
1188 "invalid part name",
1189 ));
1190 };
1191 let mut existing = parent;
1192 while !existing.exists() {
1193 existing = existing.parent().ok_or_else(escape)?;
1194 }
1195 if !fs::is_within_or_eq(root_abs, &existing.canonicalize()?) {
1196 return Err(escape());
1197 }
1198 if !parent.is_dir() {
1199 if !create_parent {
1200 return Ok(None);
1201 }
1202 std::fs::create_dir_all(parent)?;
1203 }
1204 let canon = parent.canonicalize()?;
1205 if !fs::is_within_or_eq(root_abs, &canon) {
1206 return Err(escape());
1207 }
1208 let path = canon.join(name);
1209 Ok(Some(Target {
1210 exists: std::fs::symlink_metadata(&path).is_ok(),
1211 is_dir: std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
1212 path,
1213 }))
1214}
1215
1216/// Map an [`upload_target`] error to the API error: a malformed name is a
1217/// 400, everything else (escape, io) a 403 as before.
1218fn target_error(e: io::Error) -> ApiError {
1219 if e.kind() == io::ErrorKind::InvalidInput {
1220 return ApiError::localized(
1221 StatusCode::BAD_REQUEST,
1222 "invalid part name",
1223 "err_bad_part_name",
1224 );
1225 }
1226 tracing::warn!(error = %e, "upload parent rejected");
1227 ApiError::localized(
1228 StatusCode::FORBIDDEN,
1229 "invalid file path in upload",
1230 "err_bad_upload_path",
1231 )
1232}
1233
1234/// `POST /api/files/{root_id}/{*path}?action=exists` — which upload targets
1235/// already exist. Read-only: no directory is created. The client asks this
1236/// before uploading so the overwrite question comes before the transfer.
1237async fn exists(
1238 state: Arc<AppState>,
1239 auth: AuthUser,
1240 root_id: i64,
1241 req_rel: String,
1242 body: ExistsReq,
1243) -> Result<Json<ExistsResp>, ApiError> {
1244 if body.paths.len() > 10_000 {
1245 return Err(ApiError::localized(
1246 StatusCode::BAD_REQUEST,
1247 "too many paths",
1248 "err_too_many_paths",
1249 ));
1250 }
1251 for p in &body.paths {
1252 validate_rel_path(p)?;
1253 }
1254 let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
1255 let existing = tokio::task::spawn_blocking(move || {
1256 let mut out = Vec::new();
1257 for path in body.paths {
1258 if let Some(t) = upload_target(&root_abs, &base, &path, false)?
1259 && t.exists
1260 {
1261 out.push(Existing {
1262 path,
1263 is_dir: t.is_dir,
1264 });
1265 }
1266 }
1267 Ok::<_, io::Error>(out)
1268 })
1269 .await
1270 .map_err(|_| io::Error::other("join"))?
1271 .map_err(target_error)?;
1272 Ok(Json(ExistsResp { existing }))
1273}
1274
1275/// Outcome of [`publish`].
1276enum Published {
1277 Written,
1278 /// The target exists and `overwrite` was off. Nothing was replaced.
1279 Exists,
1280}
1281
1282/// Move the finished scratch file to `target`. With `overwrite`, `rename`
1283/// replaces whatever is there. Without it the target must not exist at the
1284/// moment of publishing: `hard_link` fails with `AlreadyExists` atomically,
1285/// which closes the window between the pre-upload stat and the publish.
1286/// On success `tmp` is gone in both cases.
1287fn publish(tmp: &Path, target: &Path, overwrite: bool) -> io::Result<Published> {
1288 if overwrite {
1289 std::fs::rename(tmp, target)?;
1290 return Ok(Published::Written);
1291 }
1292 match std::fs::hard_link(tmp, target) {
1293 Ok(()) => {
1294 std::fs::remove_file(tmp)?;
1295 Ok(Published::Written)
1296 }
1297 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(Published::Exists),
1298 Err(e) if link_unsupported(&e) => {
1299 tracing::warn!(error = %e, "hard links unsupported here, falling back to stat + rename");
1300 // ponytail: stat-then-rename leaves a microsecond window in which
1301 // a file created by someone else is replaced. Closing it needs
1302 // renameat2(RENAME_NOREPLACE) through libc.
1303 if std::fs::symlink_metadata(target).is_ok() {
1304 return Ok(Published::Exists);
1305 }
1306 std::fs::rename(tmp, target)?;
1307 Ok(Published::Written)
1308 }
1309 Err(e) => Err(e),
1310 }
1311}
1312
1313/// The filesystem refuses hard links: EPERM (some network mounts, restricted
1314/// namespaces), ENOTSUP, or EXDEV. Only EXDEV needs its raw code; the other
1315/// two map to an `ErrorKind`.
1316fn link_unsupported(e: &io::Error) -> bool {
1317 matches!(
1318 e.kind(),
1319 io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
1320 ) || e.raw_os_error() == Some(18)
1321}
1322
1323/// Undo the client's `Content-Disposition` escaping (WHATWG form-data): the
1324/// three characters that cannot appear raw in a quoted header value. `multer`
1325/// does not do this itself.
1326fn decode_cd(s: &str) -> String {
1327 s.replace("%22", "\"")
1328 .replace("%0D", "\r")
1329 .replace("%0A", "\n")
1330}
1331
1332/// The `.upload-<token>` scratch file of one in-flight upload part. Dropping it
1333/// removes the file, which covers the paths no `return` can see, above all the
1334/// request future being dropped when the client closes the connection. A leaked
1335/// scratch file is never named again and shows up in listings, which include
1336/// hidden entries on purpose. [`Scratch::disarm`] after a publish skips the
1337/// unlink of a path that is now the uploaded file.
1338struct Scratch(PathBuf);
1339
1340impl Scratch {
1341 /// The scratch file is now the uploaded file: leave it alone.
1342 fn disarm(self) {
1343 std::mem::forget(self);
1344 }
1345}
1346
1347impl Drop for Scratch {
1348 fn drop(&mut self) {
1349 // Plain blocking unlink: `Drop` can run during runtime shutdown, where
1350 // `tokio::spawn` panics. One unlink cannot block meaningfully.
1351 if let Err(e) = std::fs::remove_file(&self.0)
1352 && e.kind() != io::ErrorKind::NotFound
1353 {
1354 tracing::warn!(error = %e, path = %self.0.display(), "could not remove upload scratch file");
1355 }
1356 }
1357}
1358
1359fn parse_boundary(content_type: &str) -> Option<String> {
1360 content_type
1361 .split(';')
1362 .map(|s| s.trim())
1363 .find_map(|s| s.strip_prefix("boundary="))
1364 .map(|b| b.trim_matches('"').to_string())
1365 .filter(|b| !b.is_empty())
1366}
1367
1368/// Read one query parameter from the request URI.
1369fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
1370 uri.query()?.split('&').find_map(|kv| {
1371 let (k, v) = kv.split_once('=')?;
1372 (k == key).then(|| v.to_string())
1373 })
1374}
1375
1376fn action_param(uri: &axum::http::Uri) -> Option<String> {
1377 query_param(uri, P_ACTION)
1378}
1379
1380fn parse_overwrite(uri: &axum::http::Uri) -> bool {
1381 matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
1382}
1383
1384fn validate_rel_path(name: &str) -> Result<(), ApiError> {
1385 for c in std::path::Path::new(name).components() {
1386 match c {
1387 Component::Normal(_) => {}
1388 _ => {
1389 return Err(ApiError::localized(
1390 StatusCode::BAD_REQUEST,
1391 "invalid file path in upload",
1392 "err_bad_upload_path",
1393 ));
1394 }
1395 }
1396 }
1397 Ok(())
1398}
1399
1400// ---------------------------------------------------------------------------
1401// Helpers
1402// ---------------------------------------------------------------------------
1403
1404/// Drop every share that named `abs` or anything under it.
1405///
1406/// Called after a delete, a rename, or a move: each one frees a path, and a
1407/// share stores a path, not a file identity. Without this, a *new* item that
1408/// later lands on the freed path would inherit the old link's audience.
1409///
1410/// Only covers changes made through this API. A file moved out from under the
1411/// server (over SSH, say) leaves its shares in place, still pointing at a
1412/// path. Closing that needs inode pinning, which breaks across a restore from
1413/// backup, so it is deliberately not done.
1414///
1415/// Best-effort: the file operation has already succeeded by the time this
1416/// runs, so a database error must not turn it into a 500. The client would
1417/// read that as "the delete failed" and retry, and the retry would 404. The
1418/// failure is logged at `error` instead, and leaves a share pointing at a
1419/// path that no longer holds what it did.
1420pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
1421 let target = target_rel(state, abs);
1422 match state.db.revoke_shares_at(&target).await {
1423 Ok(0) => {}
1424 Ok(n) => tracing::info!(target = %target, revoked = n, "shares revoked: path is gone"),
1425 Err(e) => {
1426 tracing::error!(error = %e, target = %target, "could not revoke shares on a freed path")
1427 }
1428 }
1429}
1430
1431fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1432 roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
1433 ApiError::localized(
1434 StatusCode::FORBIDDEN,
1435 "no such folder",
1436 "err_no_such_folder",
1437 )
1438 })
1439}
1440
1441fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
1442 let root = find_root(roots, root_id)?;
1443 if !root.mode.is_writable() {
1444 return Err(ApiError::localized(
1445 StatusCode::FORBIDDEN,
1446 "read-only folder",
1447 "err_read_only_folder",
1448 ));
1449 }
1450 Ok(root)
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456 use api_types::{ACTION_DOWNLOAD, P_FORMAT};
1457 use axum::http::Uri;
1458
1459 /// The publish step must never replace a file that appeared after the
1460 /// pre-upload stat unless `overwrite` is on.
1461 #[test]
1462 fn publish_refuses_an_existing_target_without_overwrite() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let tmp = dir.path().join(".upload-1");
1465 let target = dir.path().join("a.txt");
1466
1467 std::fs::write(&tmp, b"new").unwrap();
1468 assert!(matches!(
1469 publish(&tmp, &target, false).unwrap(),
1470 Published::Written
1471 ));
1472 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1473 assert!(!tmp.exists(), "scratch file must be gone after publish");
1474
1475 // The target exists now: no overwrite → untouched.
1476 std::fs::write(&tmp, b"racer").unwrap();
1477 assert!(matches!(
1478 publish(&tmp, &target, false).unwrap(),
1479 Published::Exists
1480 ));
1481 assert_eq!(std::fs::read(&target).unwrap(), b"new");
1482 assert!(
1483 tmp.exists(),
1484 "the caller's drop guard removes the scratch file"
1485 );
1486
1487 // With overwrite the target is replaced.
1488 assert!(matches!(
1489 publish(&tmp, &target, true).unwrap(),
1490 Published::Written
1491 ));
1492 assert_eq!(std::fs::read(&target).unwrap(), b"racer");
1493 assert!(!tmp.exists());
1494 }
1495
1496 /// A rename of `P_ACTION` or `P_FORMAT` without the matching field
1497 /// rename would silently stop the server from reading the parameter the
1498 /// client sends. This builds the query string from the constants and
1499 /// runs the real extractor over it.
1500 #[test]
1501 fn query_fields_are_the_shared_constants() {
1502 let uri: Uri = format!("/f/1/a.txt?{P_ACTION}={ACTION_DOWNLOAD}&{P_FORMAT}=zip")
1503 .parse()
1504 .unwrap();
1505 let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
1506 assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD));
1507 assert_eq!(q.format.as_deref(), Some("zip"));
1508
1509 // The same constants drive the hand-rolled readers on the POST path.
1510 assert_eq!(action_param(&uri).as_deref(), Some(ACTION_DOWNLOAD));
1511 let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=true").parse().unwrap();
1512 assert!(parse_overwrite(&uri));
1513 }
1514}
1515