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:
@@ -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
160
src/api/obj.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user