//! 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 //! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir use std::path::Component; use std::sync::Arc; use axum::extract::{Path as AxumPath, State}; use axum::http::{header, StatusCode}; use axum::Json; use multer::Multipart; use serde::Deserialize; use tokio::io::AsyncWriteExt; use crate::api::common::AuthUser; use crate::db::RootRow; use crate::error::{ApiError, AppState}; use crate::fs; // --------------------------------------------------------------------------- // Bodies / query params // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct MutationBody { pub op: String, // "rename" | "move" | "copy" #[serde(default)] pub new_name: Option, #[serde(default)] pub dst_root_id: Option, #[serde(default)] pub dst: Option, #[serde(default)] pub overwrite: bool, } // --------------------------------------------------------------------------- // Listing // --------------------------------------------------------------------------- /// GET /api/files/{root_id}/{*path} pub async fn list( State(state): State>, auth: AuthUser, path: AxumPath<(i64, String)>, ) -> Result, ApiError> { let (root_id, req_rel) = path.0; list_inner(state, auth, root_id, req_rel).await } /// GET /api/files/{root_id} — list the root directory itself. pub async fn list_root( State(state): State>, auth: AuthUser, path: AxumPath, ) -> Result, ApiError> { list_inner(state, auth, path.0, String::new()).await } async fn list_inner( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, ) -> Result, ApiError> { let root = find_root(&auth.roots, root_id)?; let server_root = state.root.clone(); let root_rel = root.path.clone(); let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; Ok(Json(serde_json::json!({ "entries": entries }))) } // --------------------------------------------------------------------------- // POST dispatch: mkdir | rename/move/copy | upload // --------------------------------------------------------------------------- /// POST /api/files/{root_id} — top-level operations (upload into the root /// directory). The bare root never targets a specific item, so JSON ops with /// a missing folder name are rejected by the individual handlers. pub async fn dispatch_root( State(state): State>, auth: AuthUser, path: AxumPath, headers: axum::http::HeaderMap, req: axum::http::Request, ) -> Result, ApiError> { dispatch_inner(state, auth, path.0, String::new(), headers, req).await } /// POST /api/files/{root_id}/{*path} pub async fn dispatch( State(state): State>, auth: AuthUser, path: AxumPath<(i64, String)>, headers: axum::http::HeaderMap, req: axum::http::Request, ) -> Result, ApiError> { let (root_id, req_rel) = path.0; dispatch_inner(state, auth, root_id, req_rel, headers, req).await } async fn dispatch_inner( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, headers: axum::http::HeaderMap, req: axum::http::Request, ) -> Result, ApiError> { let ct = headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if ct.starts_with("multipart/form-data") { return upload(state, auth, root_id, req_rel, req).await; } if ct.starts_with("application/json") { let bytes = axum::body::to_bytes(req.into_body(), 1_000_000) .await .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?; let body: MutationBody = axum::Json::from_bytes(&bytes) .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))? .0; return mutation(state, auth, root_id, req_rel, body).await; } mkdir(state, auth, root_id, req_rel).await } // --------------------------------------------------------------------------- // 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::new( StatusCode::BAD_REQUEST, "a folder name is required", )); } let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; Ok(Json(serde_json::json!({ "ok": true }))) } // --------------------------------------------------------------------------- // rename / move / copy // --------------------------------------------------------------------------- async fn mutation( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, body: MutationBody, ) -> Result, ApiError> { match body.op.as_str() { "rename" => { let new_name = body .new_name .as_deref() .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is 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); tokio::task::spawn_blocking(move || fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; Ok(Json(serde_json::json!({ "ok": true }))) } "move" | "copy" => { let dst_root_id = body .dst_root_id .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is 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 src_root = if body.op == "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 op_is_move = body.op == "move"; tokio::task::spawn_blocking(move || { if op_is_move { fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite) } else { fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite) } }) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; Ok(Json(serde_json::json!({ "ok": true }))) } _ => Err(ApiError::new( StatusCode::BAD_REQUEST, "unknown op (expected rename, move or copy)", )), } } // --------------------------------------------------------------------------- // DELETE // --------------------------------------------------------------------------- pub async fn delete( State(state): State>, auth: AuthUser, path: AxumPath<(i64, String)>, ) -> Result, ApiError> { let (root_id, req_rel) = path.0; let root = require_rw_root(&auth.roots, root_id)?; if req_rel.trim().is_empty() { return Err(ApiError::new( StatusCode::BAD_REQUEST, "a path inside the folder is required", )); } let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); let is_dir = tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir }))) } // --------------------------------------------------------------------------- // Upload (multipart) // --------------------------------------------------------------------------- async fn upload( state: Arc, auth: AuthUser, root_id: i64, req_rel: String, req: axum::http::Request, ) -> Result, ApiError> { let root = require_rw_root(&auth.roots, root_id)?; let base = { let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel); tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?? }; let boundary = req .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .and_then(parse_boundary) .ok_or_else(|| { ApiError::new( StatusCode::BAD_REQUEST, "expected multipart/form-data with a boundary", ) })?; let overwrite = parse_overwrite(req.uri()); 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(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))? { let part_name = field .name() .filter(|n| !n.is_empty()) .or_else(|| field.file_name()) .map(str::to_string) .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?; validate_rel_path(&part_name)?; let target = base.join(&part_name); let parent = target .parent() .filter(|p| !p.as_os_str().is_empty()) .ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?; if !parent.is_dir() { let p = parent.to_path_buf(); tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; } if target.exists() && !overwrite { // Drain this part and report it as a conflict at the end. while let Some(_chunk) = field .chunk() .await .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))? {} skipped.push(part_name); continue; } if target.exists() { if target.is_dir() { return Err(ApiError::new( StatusCode::CONFLICT, "a folder with this name already exists", )); } tokio::fs::remove_file(&target) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?; } // Stream to a temp file in the same directory, then rename into place. let suffix = crate::auth::random_token(); let tmp = parent.join(format!(".upload-{suffix}")); let mut tmp_file = tokio::fs::File::create(&tmp) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?; let write_failed = loop { match field .chunk() .await .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data")) { Ok(Some(chunk)) => { if let Err(e) = tmp_file.write_all(&chunk).await { tracing::warn!(error = %e, "write failed during upload"); break true; } } Ok(None) => break false, Err(e) => return Err(e), } }; if write_failed { let _ = tokio::fs::remove_file(&tmp).await; return Err(ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "could not save the file", )); } let tmp2 = tmp.clone(); let target2 = target.clone(); tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")) .map_err(|e| e)? .map_err(|e| { let _ = std::fs::remove_file(&tmp); tracing::warn!(error = %e, "rename failed during upload"); ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error") })?; uploaded += 1; } if uploaded == 0 && skipped.is_empty() { return Err(ApiError::new( StatusCode::BAD_REQUEST, "no files were uploaded", )); } if !skipped.is_empty() { return Err( ApiError::new( StatusCode::CONFLICT, "some files already exist", ) .with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })), ); } Ok(Json(serde_json::json!({ "ok": true, "uploaded": uploaded }))) } fn parse_boundary(content_type: &str) -> Option { content_type .split(';') .map(|s| s.trim()) .find_map(|s| s.strip_prefix("boundary=")) .map(|b| b.trim_matches('"').to_string()) .filter(|b| !b.is_empty()) } fn parse_overwrite(uri: &axum::http::Uri) -> bool { uri.query() .map(|q| { q.split('&') .any(|kv| kv == "overwrite=true" || kv == "overwrite=1") }) .unwrap_or(false) } fn validate_rel_path(name: &str) -> Result<(), ApiError> { for c in std::path::Path::new(name).components() { match c { Component::Normal(_) => {} _ => { return Err(ApiError::new( StatusCode::BAD_REQUEST, "invalid file path in upload", )) } } } Ok(()) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn find_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> { roots .iter() .find(|r| r.id == root_id) .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder")) } fn require_rw_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> { let root = find_root(roots, root_id)?; if root.mode != "rw" { return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder")); } Ok(root) }