feat: add object storage REST API

File-based object storage with GET/PUT/DELETE, directory listing,
path traversal protection, and streaming upload up to 2GB.
This commit is contained in:
Fam Zheng
2026-03-04 11:46:55 +00:00
parent ae72e699f4
commit a3c9fbe8e5
2 changed files with 163 additions and 0 deletions

View File

@@ -1,5 +1,7 @@
mod kb;
pub mod obj;
mod projects;
mod settings;
mod timers;
mod workflows;
@@ -28,6 +30,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.merge(workflows::router(state.clone()))
.merge(timers::router(state.clone()))
.merge(kb::router(state.clone()))
.merge(settings::router(state.clone()))
.route("/projects/{id}/files/{*path}", get(serve_project_file))
.route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone()))
.route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state))

160
src/api/obj.rs Normal file
View File

@@ -0,0 +1,160 @@
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<PathBuf, Response> {
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<PathBuf, Response> {
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<String>) -> Result<Response, Response> {
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<Response, Response> {
dir_json(Path::new(&root), "").await
}
async fn obj_get(
State(root): State<String>,
AxumPath(obj_path): AxumPath<String>,
) -> Result<Response, Response> {
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<String>,
AxumPath(obj_path): AxumPath<String>,
body: Body,
) -> Result<Response, Response> {
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<String>,
AxumPath(obj_path): AxumPath<String>,
) -> Result<Response, Response> {
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<Response, Response> {
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())
}