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