//! File operations API. //! //! All operations live on the same URL shape as the listing, distinguished by //! method (and content type for POST): //! //! - `GET /api/files/{root_id}` and `/api/files/{root_id}/{*path}` — list //! - `DELETE /api/files/{root_id}/{*path}` — delete a file or folder //! - `POST /api/files/{root_id}/{*path}` — create a folder (no body) //! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy, //! or `?action=exists` — which upload targets already exist //! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir use std::io::{self, Read}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use axum::Json; use axum::extract::{Path as AxumPath, Query as AxumQuery, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use futures_util::StreamExt; use multer::Multipart; use serde::Deserialize; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; use tower_http::services::ServeFile; use crate::api::common::{AuthUser, blocking, target_rel}; use crate::archive::{self, ArchiveFormat}; use crate::db::{RootRow, ShareRow}; use crate::error::{ApiError, AppState}; use crate::fs::{self, FsError}; use api_types::{ Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, SaveResp, SortKey, }; /// Upper bound for the in-memory text endpoint (preview, later editor). pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024; // --------------------------------------------------------------------------- // Query params // --------------------------------------------------------------------------- /// Query params for every file route. Without `action` a `GET` lists the /// directory, and the listing params pick the page; /// `?action=download|preview|content` serve the item itself. /// /// The field names are the shared `P_*` constants. /// `#[serde(rename)]` only takes a literal, so that link cannot be written /// here; `tests::query_fields_are_the_shared_constants` pins it instead. #[derive(Deserialize)] pub struct FileQuery { action: Option, format: Option, /// Upload: replace existing files. `true` or `1`. overwrite: Option, #[serde(default)] sort: SortKey, #[serde(default)] desc: bool, #[serde(default)] offset: usize, limit: Option, #[serde(default)] dirs: bool, around: Option, } impl FileQuery { fn overwrite(&self) -> bool { matches!(self.overwrite.as_deref(), Some("true" | "1")) } } /// Path params of both file routes. The bare `{root_id}` route has no path: /// it names the root item itself, which for a *file* share is the file. #[derive(Deserialize)] pub struct Loc { root_id: i64, #[serde(default)] path: String, } // --------------------------------------------------------------------------- // Listing // --------------------------------------------------------------------------- /// GET — list a directory, or serve the item itself via /// `?action=download|preview|content|thumb`. pub async fn file_get( State(state): State>, auth: AuthUser, AxumPath(Loc { root_id, path: req_rel, }): AxumPath, AxumQuery(query): AxumQuery, headers: HeaderMap, ) -> Result { match query.action.as_deref() { Some(api_types::ACTION_DOWNLOAD) => { download( state, auth, root_id, req_rel, query.format.as_deref(), &headers, ) .await } Some(api_types::ACTION_PREVIEW) => preview(state, auth, root_id, req_rel, &headers).await, Some(api_types::ACTION_CONTENT) => content(state, auth, root_id, req_rel).await, Some(api_types::ACTION_THUMB) => thumb(state, auth, root_id, req_rel).await, _ => { let opts = fs::ListOpts { sort: query.sort, desc: query.desc, offset: query.offset, limit: query.limit, dirs_only: query.dirs, around: query.around, }; let json = list_inner(state, auth, root_id, req_rel, opts).await?; Ok(json.into_response()) } } } /// Caching policy for a served file. /// /// `private` because the response depends on who asked. `no-cache`, not /// `no-store`, so a client can revalidate a large media file and get a `304` /// instead of re-downloading it. const FILE_CACHE: &str = "private, no-cache"; /// Whether an mtime (unix seconds) may be a `Last-Modified` validator. Not an /// unknown mtime (0), and not one from the last two seconds: whole-second /// dates cannot tell two writes in one second apart. fn trusted_mtime(mtime: i64) -> bool { mtime > 0 && mtime + 2 <= chrono::Utc::now().timestamp() } async fn list_inner( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, opts: fs::ListOpts, ) -> Result, ApiError> { // A file share's root is the file itself: there is nothing to list. if auth.share.as_ref().is_some_and(|s| s.is_file) { return Err(FsError::NotADirectory.into()); } let root = find_root(&auth.roots, root_id)?; let server_root = state.root.clone(); let root_rel = root.path.clone(); // Resolve and list in one blocking hop: both are filesystem work. let resp = blocking(move || { let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?; fs::list_dir(&full, opts) }) .await?; Ok(Json(resp)) } // --------------------------------------------------------------------------- // download / preview / content (milestone 4) // --------------------------------------------------------------------------- /// `Content-Disposition` parameters for `name`: an ASCII `filename=` fallback /// (non-ASCII and control bytes become `_`) plus the RFC 8187 `filename*=` /// that every current browser reads. Never fails header validation. pub(crate) fn disposition(kind: &str, name: &str) -> String { let ascii: String = name .chars() .map(|c| match c { '"' | '\\' => '_', c if c.is_ascii_graphic() || c == ' ' => c, _ => '_', }) .collect(); let mut enc = String::with_capacity(name.len() * 3); for b in name.bytes() { // attr-char per RFC 8187. if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) { enc.push(b as char); } else { use std::fmt::Write as _; let _ = write!(enc, "%{b:02X}"); } } format!("{kind}; filename=\"{ascii}\"; filename*=UTF-8''{enc}") } /// Resolve the requested item to an absolute path + metadata (blocking). async fn resolve_item( state: &AppState, root: &RootRow, req_rel: &str, share: Option<&ShareRow>, ) -> Result<(std::path::PathBuf, String, bool, u64, i64), ApiError> { let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string()); // A file share's synthetic root *is* the file, so resolve it directly. let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone()); blocking(move || { let full = match share_target { Some(target) => fs::resolve_file(&server_root, &target)?, None => fs::resolve_path(&server_root, &root_rel, &rel)?, }; let name = full .file_name() .map(|n| n.to_string_lossy().into_owned()) .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?; let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?; let mtime = fs::mtime_secs(&meta).unwrap_or(0); Ok::<_, FsError>((full, name, meta.is_dir(), meta.len(), mtime)) }) .await } /// `GET ...?action=download` — a single file as-is, a folder as an archive /// (format chosen by the client). async fn download( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, format: Option<&str>, headers: &HeaderMap, ) -> Result { let root = find_root(&auth.roots, root_id)?; let (full, name, is_dir, _size, mtime) = resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?; if !is_dir { return file_response(&full, &name, false, mtime, headers).await; } let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| { ApiError::localized( StatusCode::BAD_REQUEST, "format must be one of: zip, tar, tar.gz, tar.zst", "err_bad_format", ) })?; let disp = disposition("attachment", &format!("{name}.{}", fmt.extension())); let body = stream_archive(fmt, full, name); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, fmt.mime()) .header(header::CONTENT_DISPOSITION, disp) .header(header::CACHE_CONTROL, FILE_CACHE) .body(body) .map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("bad response: {e}"), ) }) } /// `GET ...?action=preview` — a single file, inline (for native media). async fn preview( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, headers: &HeaderMap, ) -> Result { let root = find_root(&auth.roots, root_id)?; let (full, name, is_dir, _size, mtime) = resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?; if is_dir { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "not a file", "err_not_a_file", )); } file_response(&full, &name, true, mtime, headers).await } /// `GET ...?action=content` — raw file bytes for the text preview/editor. /// Capped at `MAX_TEXT_BYTES`. async fn content( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, ) -> Result { let root = find_root(&auth.roots, root_id)?; let (full, _name, is_dir, size, _mtime) = resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?; if is_dir { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "not a file", "err_not_a_file", )); } if size > MAX_TEXT_BYTES { return Err(ApiError::localized( StatusCode::PAYLOAD_TOO_LARGE, "file too large to preview", "err_too_large_preview", )); } // mtime and bytes from one handle, mtime first: a write in between would // otherwise hand the editor a stale conflict anchor for fresh content. let (mtime, bytes) = blocking(move || -> io::Result<(i64, Vec)> { let mut f = std::fs::File::open(&full)?; let mtime = fs::mtime_secs(&f.metadata()?).unwrap_or(0); let mut bytes = Vec::with_capacity(size as usize); f.read_to_end(&mut bytes)?; Ok((mtime, bytes)) }) .await?; Ok(( [ ( header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string(), ), (header::CACHE_CONTROL, FILE_CACHE.to_string()), ( axum::http::HeaderName::from_static("x-file-mtime"), mtime.to_string(), ), ], bytes, ) .into_response()) } /// `GET ...?action=thumb` — a small WebP preview of an image or video. /// /// `404` covers every "no thumbnail here" case: thumbnails switched off, a /// folder, a kind we do not render, an undecodable file, a video without /// ffmpeg. The grid falls back to its icon for all of them alike. async fn thumb( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, ) -> Result { let Some(thumbs) = state.thumbs.as_ref() else { return Err(no_thumb()); }; let root = find_root(&auth.roots, root_id)?; let (full, _name, is_dir, size, mtime) = resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?; if is_dir { return Err(no_thumb()); } // The kind is sniffed from the bytes, not the name, so an mp4 called .txt // still gets a thumbnail and a .jpg full of text does not. let kind = { let full = full.clone(); blocking(move || Ok::<_, FsError>(fs::detect_kind(&full, false))).await? }; let Some(bytes) = thumbs.get(&full, kind, size, mtime).await else { return Err(no_thumb()); }; Ok(( [ (header::CONTENT_TYPE, "image/webp"), // Safe despite the stable path: the client varies the query on // mtime, so new content always means a new URL. ( header::CACHE_CONTROL, "private, max-age=31536000, immutable", ), ], bytes, ) .into_response()) } /// The message never reaches a user: an `` 404 just leaves the tile's /// icon showing. That is why it carries no localized code. fn no_thumb() -> ApiError { ApiError::new(StatusCode::NOT_FOUND, "no thumbnail".to_string()) } /// `PUT ...?action=content` — save a file's text contents (the editor). /// /// Requires a read-write root. The body is the new contents. If the /// `X-Expected-Mtime` header is present, the file's current mtime must match /// it, otherwise `409 Conflict` (the file changed on disk since it was read). /// Returns the file's new mtime so the client can anchor the next check. /// /// On the bare root only a *file* share gets past the resolve: for a folder /// the root is a directory, which is rejected. pub async fn file_put( State(state): State>, auth: AuthUser, AxumPath(Loc { root_id, path: req_rel, }): AxumPath, AxumQuery(query): AxumQuery, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, ApiError> { if query.action.as_deref() != Some(api_types::ACTION_CONTENT) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "expected action=content", "err_bad_action", )); } let root = require_rw_root(&auth.roots, root_id)?; if body.len() as u64 > MAX_TEXT_BYTES { return Err(ApiError::localized( StatusCode::PAYLOAD_TOO_LARGE, "file too large to save", "err_too_large_save", )); } let expected: Option = headers .get("x-expected-mtime") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()); // A file share's synthetic root *is* the file, so resolve it directly. let share_target = auth .share .as_ref() .filter(|s| s.is_file) .map(|s| s.target.clone()); let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); let content = body.to_vec(); let mtime = blocking(move || match share_target { Some(target) => fs::save_file_at(&server_root, &target, &content, expected), None => fs::save_file(&server_root, &root_rel, &rel, &content, expected), }) .await?; Ok(Json(SaveResp { mtime })) } /// Serve a single file with the right disposition. `ServeFile` answers /// `Range` (a 206 even for `bytes=0-`, which Firefox needs) and /// `If-Modified-Since`. async fn file_response( full: &Path, name: &str, inline: bool, mtime: i64, headers: &HeaderMap, ) -> Result { let mut req = axum::http::Request::new(()); if let Some(range) = headers.get(header::RANGE) { req.headers_mut().insert(header::RANGE, range.clone()); } if trusted_mtime(mtime) && let Some(ims) = headers.get(header::IF_MODIFIED_SINCE) { req.headers_mut() .insert(header::IF_MODIFIED_SINCE, ims.clone()); } // `ServeFile` panics on an mtime it cannot write as an HTTP date: one // before 1970, or in the year 9999 or later. let meta = tokio::fs::metadata(full).await.ok(); let dateable = meta .as_ref() .and_then(|m| m.modified().ok()) .is_none_or(|t| { t.duration_since(std::time::UNIX_EPOCH) .is_ok_and(|d| d.as_secs() < YEAR_9999) }); let mut res = match meta { Some(m) if !dateable => whole_file(full.to_path_buf(), m.len()), _ => ServeFile::new(full) .try_call(req) .await .map_err(|e| { tracing::warn!(error = %e, path = %full.display(), "download open failed"); ApiError::internal() })? .map(axum::body::Body::new), }; // Judged again on the date actually served: the file may have been // written since `mtime` was read. let served = res .headers() .get(header::LAST_MODIFIED) .and_then(|v| v.to_str().ok()) .and_then(|v| chrono::DateTime::parse_from_rfc2822(v).ok()); if !served.is_some_and(|t| trusted_mtime(t.timestamp())) { res.headers_mut().remove(header::LAST_MODIFIED); } // Always sent, validator or not: with no directive a cache may apply // heuristic freshness to a response that depends on who asked. A 304 // repeats it, so the stored copy does not lose the directive. res.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static(FILE_CACHE)); // A file the browser would parse as a document (HTML/SVG/XML) is served // under the sandboxed policy, so it can render as a page without being // able to act as the app. Non-scriptable inline files (PDF, …) are // frameable by the app itself, for the preview modal. A 304 gets the // same policy: the browser copies its CSP onto the cached response, and // the router would otherwise fill in the app policy. let mime = mime_guess::from_path(full).first_or_octet_stream(); let h = res.headers_mut(); if crate::api::is_scriptable_mime(mime.essence_str()) { h.insert( "content-security-policy", HeaderValue::from_static(crate::api::FILE_CSP), ); } else if inline { h.insert( "content-security-policy", HeaderValue::from_static(crate::api::INLINE_CSP), ); h.insert( header::X_FRAME_OPTIONS, HeaderValue::from_static("SAMEORIGIN"), ); } if res.status().is_success() { let disp = disposition(if inline { "inline" } else { "attachment" }, name); res.headers_mut().insert( header::CONTENT_DISPOSITION, HeaderValue::try_from(disp).map_err(|_| ApiError::internal())?, ); } Ok(res) } /// 9999-01-01T00:00:00Z in unix seconds. const YEAR_9999: u64 = 253_402_300_800; /// The whole file as a plain 200, with no validator. // ponytail: no Range support, so a video in such a file cannot seek. Rare // enough (bogus archive timestamps); serve ranges here if it ever matters. fn whole_file(path: PathBuf, len: u64) -> Response { let mime = mime_guess::from_path(&path).first_or_octet_stream(); let body = blocking_body(move |sink| { if let Err(e) = std::fs::File::open(&path).and_then(|mut f| io::copy(&mut f, sink)) { tracing::warn!(error = %e, path = %path.display(), "download failed"); } }); ( [ (header::CONTENT_TYPE, mime.to_string()), (header::CONTENT_LENGTH, len.to_string()), ], body, ) .into_response() } /// Stream an archive of `dir` (top-level entry `top`) to the client. fn stream_archive(fmt: ArchiveFormat, dir: PathBuf, top: String) -> axum::body::Body { blocking_body(move |sink| { if let Err(e) = archive::build(fmt, &dir, &top, sink) { tracing::warn!(error = %e, dir = %dir.display(), "archive build failed"); } }) } /// A response body fed by a blocking writer on the blocking pool. fn blocking_body(write: impl FnOnce(&mut ChanWriter) + Send + 'static) -> axum::body::Body { let (tx, mut rx) = mpsc::channel::>(16); // Dropping the writer flushes its buffer and closes the channel. tokio::task::spawn_blocking(move || write(&mut ChanWriter::new(tx))); let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)).map(Ok::<_, io::Error>); axum::body::Body::from_stream(stream) } /// A `Write` that buffers chunks and forwards them over an mpsc channel — the /// bridge between the blocking archive builder and the async response body. struct ChanWriter { tx: mpsc::Sender>, buf: Vec, } impl ChanWriter { fn new(tx: mpsc::Sender>) -> Self { Self { tx, buf: Vec::with_capacity(64 * 1024), } } } impl io::Write for ChanWriter { fn write(&mut self, b: &[u8]) -> io::Result { self.buf.extend_from_slice(b); if self.buf.len() >= 64 * 1024 { io::Write::flush(self)?; } Ok(b.len()) } fn flush(&mut self) -> io::Result<()> { if !self.buf.is_empty() { let chunk = std::mem::take(&mut self.buf); self.tx .blocking_send(chunk) .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client disconnected"))?; } Ok(()) } } impl Drop for ChanWriter { fn drop(&mut self) { let _ = io::Write::flush(self); } } // --------------------------------------------------------------------------- // POST dispatch: mkdir | rename/move/copy | upload // --------------------------------------------------------------------------- /// POST — route one request to upload, mutation or mkdir. On the bare root /// only an upload into the root directory makes sense; the JSON ops reject a /// missing item name themselves. /// /// Upload and mutation are recognized by their content type. mkdir carries no /// body, so it names itself with `?action=mkdir`. Anything else is rejected: /// an unrecognized content type used to fall through to mkdir, which turned a /// typo in a header into a silently created folder. pub async fn dispatch( State(state): State>, auth: AuthUser, AxumPath(Loc { root_id, path: req_rel, }): AxumPath, AxumQuery(query): AxumQuery, headers: HeaderMap, req: axum::http::Request, ) -> Result { let ct = headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if ct.starts_with("multipart/form-data") { return Ok(upload(state, auth, root_id, req_rel, &query, req) .await? .into_response()); } if ct.starts_with("application/json") { let is_exists = query.action.as_deref() == Some(api_types::ACTION_EXISTS); let bytes = axum::body::to_bytes(req.into_body(), 1_000_000) .await .map_err(|_| { ApiError::localized( StatusCode::BAD_REQUEST, "invalid request body", "err_bad_body", ) })?; let bad_body = |_| { ApiError::localized( StatusCode::BAD_REQUEST, "invalid request body", "err_bad_body", ) }; if is_exists { let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0; return Ok(exists(state, auth, root_id, req_rel, body) .await? .into_response()); } let body: Mutation = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0; return Ok(mutation(state, auth, root_id, req_rel, body) .await? .into_response()); } match query.action.as_deref() { Some(api_types::ACTION_MKDIR) => { return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response()); } Some(api_types::ACTION_CREATE_FILE) => { return Ok(create_file(state, auth, root_id, req_rel) .await? .into_response()); } _ => {} } Err(ApiError::localized( StatusCode::UNSUPPORTED_MEDIA_TYPE, "POST expects a multipart upload, a JSON mutation, or ?action=mkdir", "err_bad_post", )) } // --------------------------------------------------------------------------- // create file // --------------------------------------------------------------------------- /// Create an empty file in a writable root. async fn create_file( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, ) -> Result, ApiError> { let root = require_rw_root(&auth.roots, root_id)?; if req_rel.trim().is_empty() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "a file name is required", "err_file_name_required", )); } let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); blocking(move || fs::create_file(&server_root, &root_rel, &rel)).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // mkdir // --------------------------------------------------------------------------- async fn mkdir( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, ) -> Result, ApiError> { let root = require_rw_root(&auth.roots, root_id)?; if req_rel.trim().is_empty() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "a folder name is required", "err_folder_name_required", )); } let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); blocking(move || fs::mkdir(&server_root, &root_rel, &rel)).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // rename / move / copy // --------------------------------------------------------------------------- async fn mutation( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, body: Mutation, ) -> Result, ApiError> { match body.op { Op::Rename => { let new_name = body .new_name .as_deref() .ok_or_else(|| { ApiError::localized( StatusCode::BAD_REQUEST, "new_name is required", "err_new_name_required", ) })? .to_string(); let root = require_rw_root(&auth.roots, root_id)?; let (server_root, root_rel, rel, overwrite) = ( state.root.clone(), root.path.clone(), req_rel, body.overwrite, ); let vacated = blocking(move || { fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite) }) .await?; revoke_shares_at(&state, &vacated).await; Ok(Json(OkResp {})) } Op::Move | Op::Copy => { let dst_root_id = body.dst_root_id.ok_or_else(|| { ApiError::localized( StatusCode::BAD_REQUEST, "dst_root_id is required", "err_dst_required", ) })?; let dst = body.dst.clone().unwrap_or_default(); // Moving or copying out of a folder requires rw there; copying // *from* a read-only root is fine. let op_is_move = body.op == Op::Move; let src_root = if op_is_move { require_rw_root(&auth.roots, root_id)? } else { find_root(&auth.roots, root_id)? }; let dst_root = require_rw_root(&auth.roots, dst_root_id)?; let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = ( state.root.clone(), src_root.path.clone(), dst_root.path.clone(), dst, req_rel, body.overwrite, ); let vacated = blocking(move || { if op_is_move { fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite) .map(Some) } else { // A copy frees no path, so it revokes nothing. fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite) .map(|()| None) } }) .await?; if let Some(vacated) = vacated { revoke_shares_at(&state, &vacated).await; } Ok(Json(OkResp {})) } } } // --------------------------------------------------------------------------- // DELETE // --------------------------------------------------------------------------- pub async fn delete( State(state): State>, auth: AuthUser, AxumPath(Loc { root_id, path: req_rel, }): AxumPath, ) -> Result, ApiError> { let root = require_rw_root(&auth.roots, root_id)?; if req_rel.trim().is_empty() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "a path inside the folder is required", "err_path_required", )); } let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); let gone = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?; revoke_shares_at(&state, &gone).await; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Upload (multipart) // --------------------------------------------------------------------------- fn bad_upload(_: E) -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "invalid upload data", "err_bad_upload", ) } async fn upload( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, query: &FileQuery, req: axum::http::Request, ) -> Result, ApiError> { let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?; let boundary = req .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .and_then(|ct| multer::parse_boundary(ct).ok()) .ok_or_else(|| { ApiError::localized( StatusCode::BAD_REQUEST, "expected multipart/form-data with a boundary", "err_bad_multipart", ) })?; let overwrite = query.overwrite(); let stream = req.into_body().into_data_stream(); let mut multipart = Multipart::new(stream, boundary); let mut uploaded: usize = 0; let mut skipped: Vec = Vec::new(); while let Some(mut field) = multipart.next_field().await.map_err(bad_upload)? { let part_name = field .name() .filter(|n| !n.is_empty()) .or_else(|| field.file_name()) .map(decode_cd) .ok_or_else(|| { ApiError::localized( StatusCode::BAD_REQUEST, "part without a name", "err_part_no_name", ) })?; validate_rel_path(&part_name)?; let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone()); let target = blocking(move || upload_target(&r, &b, &rel, true).map_err(target_error)) .await? .expect("create_parent resolves every parent"); let (exists, is_dir) = (target.exists, target.is_dir); let target = target.path; // A folder can never be replaced by a file, even with `overwrite`. // Report it like a conflict so the client fails this one part, not // the request: a rejected request makes it retry every other file // alone. if exists && (!overwrite || is_dir) { // Drain this part and report it as a conflict at the end. while field.chunk().await.map_err(bad_upload)?.is_some() {} skipped.push(part_name); continue; } // Stream to a temp file in the same directory, then publish. let suffix = crate::auth::random_token(); let tmp = Scratch( target .parent() .expect("has parent") .join(format!(".upload-{suffix}")), ); let tmp_file = tokio::fs::File::create(&tmp.0).await?; // Buffered: a multipart chunk is often a few kilobytes, and each // unbuffered write would be its own syscall. let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file); let write_failed = loop { match field.chunk().await.map_err(bad_upload)? { Some(chunk) => { if let Err(e) = tmp_file.write_all(&chunk).await { tracing::warn!(error = %e, "write failed during upload"); break true; } } None => break tmp_file.flush().await.is_err(), } }; if write_failed { return Err(ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "could not save the file", "err_save_failed", )); } let tmp_path = tmp.0.clone(); let published = blocking(move || { publish(&tmp_path, &target, overwrite).map_err(|e| { tracing::warn!(error = %e, path = %target.display(), "publish failed during upload"); ApiError::internal() }) }) .await?; if let Published::Exists = published { // The target appeared while the body streamed in. The drop guard // removes the scratch file. skipped.push(part_name); continue; } tmp.disarm(); uploaded += 1; } if uploaded == 0 && skipped.is_empty() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "no files were uploaded", "err_no_files_uploaded", )); } if !skipped.is_empty() { return Err(ApiError::localized( StatusCode::CONFLICT, "some files already exist", "err_files_exist", ) .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded }))); } Ok(Json(OkResp {})) } /// The rw root and the upload directory. `root_abs` is the containment /// boundary (a symlink may legitimately point elsewhere inside it), `base` /// the directory the request names. async fn upload_base( state: &AppState, auth: &AuthUser, root_id: i64, req_rel: String, ) -> Result<(PathBuf, PathBuf), ApiError> { let root = require_rw_root(&auth.roots, root_id)?; let (server_root, root_rel) = (state.root.clone(), root.path.clone()); blocking(move || { Ok::<_, FsError>(( fs::resolve_root(&server_root, &root_rel)?, fs::resolve_dir(&server_root, &root_rel, &req_rel)?, )) }) .await } /// One upload target on disk. `path` has a canonical parent that is inside /// the root. struct Target { path: PathBuf, exists: bool, is_dir: bool, } /// Resolve `rel` under `base` for an upload. The nearest existing ancestor /// and the final parent are both checked against `root_abs`, so nothing is /// created or written outside the root even through a symlinked directory. /// With `create_parent` missing directories are created. Without it a /// missing parent returns `None`: the target cannot exist. /// /// `exists` uses `symlink_metadata`, so a dangling symlink counts as /// existing and is not silently replaced. fn upload_target( root_abs: &Path, base: &Path, rel: &str, create_parent: bool, ) -> io::Result> { let escape = || io::Error::other("upload parent escapes the root"); let full = base.join(rel); let (Some(parent), Some(name)) = ( full.parent().filter(|p| !p.as_os_str().is_empty()), full.file_name(), ) else { return Err(io::Error::new( io::ErrorKind::InvalidInput, "invalid part name", )); }; let mut existing = parent; while !existing.exists() { existing = existing.parent().ok_or_else(escape)?; } if !fs::is_within_or_eq(root_abs, &existing.canonicalize()?) { return Err(escape()); } if !parent.is_dir() { if !create_parent { return Ok(None); } std::fs::create_dir_all(parent)?; } let canon = parent.canonicalize()?; if !fs::is_within_or_eq(root_abs, &canon) { return Err(escape()); } let path = canon.join(name); Ok(Some(Target { exists: std::fs::symlink_metadata(&path).is_ok(), is_dir: std::fs::metadata(&path).is_ok_and(|m| m.is_dir()), path, })) } /// Map an [`upload_target`] error to the API error: a malformed name is a /// 400, everything else (escape, io) a 403 as before. fn target_error(e: io::Error) -> ApiError { if e.kind() == io::ErrorKind::InvalidInput { return ApiError::localized( StatusCode::BAD_REQUEST, "invalid part name", "err_bad_part_name", ); } tracing::warn!(error = %e, "upload parent rejected"); ApiError::localized( StatusCode::FORBIDDEN, "invalid file path in upload", "err_bad_upload_path", ) } /// `POST /api/files/{root_id}/{*path}?action=exists` — which upload targets /// already exist. Read-only: no directory is created. The client asks this /// before uploading so the overwrite question comes before the transfer. async fn exists( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, body: ExistsReq, ) -> Result, ApiError> { if body.paths.len() > 10_000 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "too many paths", "err_too_many_paths", )); } for p in &body.paths { validate_rel_path(p)?; } let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?; let existing = blocking(move || { let mut out = Vec::new(); for path in body.paths { if let Some(t) = upload_target(&root_abs, &base, &path, false).map_err(target_error)? && t.exists { out.push(Existing { path, is_dir: t.is_dir, }); } } Ok::<_, ApiError>(out) }) .await?; Ok(Json(ExistsResp { existing })) } /// Outcome of [`publish`]. enum Published { Written, /// The target exists and `overwrite` was off. Nothing was replaced. Exists, } /// Move the finished scratch file to `target`. With `overwrite`, `rename` /// replaces whatever is there. Without it the target must not exist at the /// moment of publishing: `hard_link` fails with `AlreadyExists` atomically, /// which closes the window between the pre-upload stat and the publish. /// On success `tmp` is gone in both cases. fn publish(tmp: &Path, target: &Path, overwrite: bool) -> io::Result { if overwrite { std::fs::rename(tmp, target)?; return Ok(Published::Written); } match std::fs::hard_link(tmp, target) { Ok(()) => { std::fs::remove_file(tmp)?; Ok(Published::Written) } Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(Published::Exists), Err(e) if link_unsupported(&e) => { tracing::warn!(error = %e, "hard links unsupported here, falling back to stat + rename"); // ponytail: stat-then-rename leaves a microsecond window in which // a file created by someone else is replaced. Closing it needs // renameat2(RENAME_NOREPLACE) through libc. if std::fs::symlink_metadata(target).is_ok() { return Ok(Published::Exists); } std::fs::rename(tmp, target)?; Ok(Published::Written) } Err(e) => Err(e), } } /// The filesystem refuses hard links: EPERM (some network mounts, restricted /// namespaces), ENOTSUP, or EXDEV. Only EXDEV needs its raw code; the other /// two map to an `ErrorKind`. fn link_unsupported(e: &io::Error) -> bool { matches!( e.kind(), io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported ) || e.raw_os_error() == Some(18) } /// Undo the client's `Content-Disposition` escaping (WHATWG form-data): the /// three characters that cannot appear raw in a quoted header value. `multer` /// does not do this itself. fn decode_cd(s: &str) -> String { s.replace("%22", "\"") .replace("%0D", "\r") .replace("%0A", "\n") } /// The `.upload-` scratch file of one in-flight upload part. Dropping it /// removes the file, which covers the paths no `return` can see, above all the /// request future being dropped when the client closes the connection. A leaked /// scratch file is never named again and shows up in listings, which include /// hidden entries on purpose. [`Scratch::disarm`] after a publish skips the /// unlink of a path that is now the uploaded file. struct Scratch(PathBuf); impl Scratch { /// The scratch file is now the uploaded file: leave it alone. fn disarm(self) { std::mem::forget(self); } } impl Drop for Scratch { fn drop(&mut self) { // Plain blocking unlink: `Drop` can run during runtime shutdown, where // `tokio::spawn` panics. One unlink cannot block meaningfully. if let Err(e) = std::fs::remove_file(&self.0) && e.kind() != io::ErrorKind::NotFound { tracing::warn!(error = %e, path = %self.0.display(), "could not remove upload scratch file"); } } } fn validate_rel_path(name: &str) -> Result<(), ApiError> { for c in std::path::Path::new(name).components() { match c { Component::Normal(_) => {} _ => { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "invalid file path in upload", "err_bad_upload_path", )); } } } Ok(()) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Drop every share that named `abs` or anything under it. /// /// Called after a delete, a rename, or a move: each one frees a path, and a /// share stores a path, not a file identity. Without this, a *new* item that /// later lands on the freed path would inherit the old link's audience. /// /// Only covers changes made through this API. A file moved out from under the /// server (over SSH, say) leaves its shares in place, still pointing at a /// path. Closing that needs inode pinning, which breaks across a restore from /// backup, so it is deliberately not done. /// /// Best-effort: the file operation has already succeeded by the time this /// runs, so a database error must not turn it into a 500. The client would /// read that as "the delete failed" and retry, and the retry would 404. The /// failure is logged at `error` instead, and leaves a share pointing at a /// path that no longer holds what it did. pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) { let target = target_rel(state, abs); match state.db.revoke_shares_at(&target).await { Ok(0) => {} Ok(n) => tracing::info!(target = %target, revoked = n, "shares revoked: path is gone"), Err(e) => { tracing::error!(error = %e, target = %target, "could not revoke shares on a freed path") } } } pub(crate) fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> { roots.iter().find(|r| r.id == root_id).ok_or_else(|| { ApiError::localized( StatusCode::FORBIDDEN, "no such folder", "err_no_such_folder", ) }) } fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> { let root = find_root(roots, root_id)?; if !root.mode.is_writable() { return Err(ApiError::localized( StatusCode::FORBIDDEN, "read-only folder", "err_read_only_folder", )); } Ok(root) } #[cfg(test)] mod tests { use super::*; use api_types::{ ACTION_DOWNLOAD, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_SORT, }; use axum::http::Uri; /// The publish step must never replace a file that appeared after the /// pre-upload stat unless `overwrite` is on. #[test] fn publish_refuses_an_existing_target_without_overwrite() { let dir = tempfile::tempdir().unwrap(); let tmp = dir.path().join(".upload-1"); let target = dir.path().join("a.txt"); std::fs::write(&tmp, b"new").unwrap(); assert!(matches!( publish(&tmp, &target, false).unwrap(), Published::Written )); assert_eq!(std::fs::read(&target).unwrap(), b"new"); assert!(!tmp.exists(), "scratch file must be gone after publish"); // The target exists now: no overwrite → untouched. std::fs::write(&tmp, b"racer").unwrap(); assert!(matches!( publish(&tmp, &target, false).unwrap(), Published::Exists )); assert_eq!(std::fs::read(&target).unwrap(), b"new"); assert!( tmp.exists(), "the caller's drop guard removes the scratch file" ); // With overwrite the target is replaced. assert!(matches!( publish(&tmp, &target, true).unwrap(), Published::Written )); assert_eq!(std::fs::read(&target).unwrap(), b"racer"); assert!(!tmp.exists()); } /// A rename of a `P_*` constant without the matching field /// rename would silently stop the server from reading the parameter the /// client sends. This builds the query string from the constants and /// runs the real extractor over it. #[test] fn query_fields_are_the_shared_constants() { let uri: Uri = format!("/f/1/a.txt?{P_ACTION}={ACTION_DOWNLOAD}&{P_FORMAT}=zip") .parse() .unwrap(); let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0; assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD)); assert_eq!(q.format.as_deref(), Some("zip")); let list_uri: Uri = format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true&{P_AROUND}=a.txt") .parse() .unwrap(); let q: FileQuery = AxumQuery::try_from_uri(&list_uri).unwrap().0; assert_eq!( ( q.sort, q.desc, q.offset, q.limit, q.dirs, q.around.as_deref() ), (SortKey::Size, true, 5, Some(10), true, Some("a.txt")) ); let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=1").parse().unwrap(); let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0; assert!(q.overwrite()); } }