use std::path::{Path, PathBuf}; use axum::{ body::Body, extract::{DefaultBodyLimit, Path as AxumPath, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use futures::StreamExt; use serde_json::json; use tokio::io::AsyncWriteExt; use tokio_util::io::ReaderStream; pub fn router(obj_root: String) -> Router { Router::new() .route("/", get(list_root)) .route("/{*path}", get(obj_get).put(obj_put).delete(obj_delete)) .layer(DefaultBodyLimit::max(2 * 1024 * 1024 * 1024)) // 2 GB .with_state(obj_root) } // ---- path helpers ---- fn bad_req(msg: &str) -> Response { (StatusCode::BAD_REQUEST, msg.to_string()).into_response() } fn not_found() -> Response { StatusCode::NOT_FOUND.into_response() } fn internal(e: impl std::fmt::Display) -> Response { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() } /// Build target path, rejecting traversal attempts. fn resolve(root: &str, obj_path: &str) -> Result { if obj_path.split('/').any(|s| s == ".." || s == ".") { return Err(bad_req("invalid path")); } Ok(PathBuf::from(root).join(obj_path)) } /// Canonicalize target and verify it stays inside root. fn canon_within(root: &str, target: &Path) -> Result { let root_c = Path::new(root).canonicalize().map_err(internal)?; let target_c = target.canonicalize().map_err(|_| not_found())?; if !target_c.starts_with(&root_c) { return Err(bad_req("invalid path")); } Ok(target_c) } // ---- handlers ---- async fn list_root(State(root): State) -> Result { dir_json(Path::new(&root), "").await } /// Public entry for the `/api/obj/` trailing-slash route (mounted outside nest). pub async fn root_listing(root: String) -> Result { dir_json(Path::new(&root), "").await } async fn obj_get( State(root): State, AxumPath(obj_path): AxumPath, ) -> Result { let target = canon_within(&root, &resolve(&root, &obj_path)?)?; if target.is_dir() { return dir_json(&target, &obj_path).await; } let file = tokio::fs::File::open(&target) .await .map_err(|_| not_found())?; let stream = ReaderStream::new(file); let body = Body::from_stream(stream); let filename = target .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); let mime = mime_guess::from_path(&target) .first_or_octet_stream() .to_string(); Ok(Response::builder() .header(header::CONTENT_TYPE, mime) .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ) .body(body) .unwrap()) } async fn obj_put( State(root): State, AxumPath(obj_path): AxumPath, body: Body, ) -> Result { if obj_path.is_empty() || obj_path.ends_with('/') { return Err(bad_req("path must be a file")); } let target = resolve(&root, &obj_path)?; if let Some(parent) = target.parent() { tokio::fs::create_dir_all(parent).await.map_err(internal)?; } let mut file = tokio::fs::File::create(&target).await.map_err(internal)?; let mut stream = body.into_data_stream(); let mut total: u64 = 0; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| bad_req(&format!("read error: {e}")))?; file.write_all(&chunk).await.map_err(internal)?; total += chunk.len() as u64; } file.flush().await.map_err(internal)?; Ok(Json(json!({"path": obj_path, "size": total})).into_response()) } async fn obj_delete( State(root): State, AxumPath(obj_path): AxumPath, ) -> Result { let target = canon_within(&root, &resolve(&root, &obj_path)?)?; if target.is_dir() { tokio::fs::remove_dir_all(&target).await.map_err(internal)?; } else { tokio::fs::remove_file(&target).await.map_err(internal)?; } Ok(Json(json!({"deleted": obj_path})).into_response()) } // ---- helpers ---- async fn dir_json(dir: &Path, display_path: &str) -> Result { let mut rd = tokio::fs::read_dir(dir).await.map_err(|_| not_found())?; let mut entries = Vec::new(); while let Some(entry) = rd.next_entry().await.map_err(internal)? { let meta = entry.metadata().await.ok(); let is_dir = meta.as_ref().is_some_and(|m| m.is_dir()); let size = if is_dir { None } else { meta.map(|m| m.len()) }; entries.push(json!({ "name": entry.file_name().to_string_lossy(), "type": if is_dir { "dir" } else { "file" }, "size": size, })); } entries.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); Ok(Json(json!({"path": display_path, "entries": entries})).into_response()) }