diff --git a/src/api/mod.rs b/src/api/mod.rs index 94f0da0..cf5cfd3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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) -> 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)) diff --git a/src/api/obj.rs b/src/api/obj.rs new file mode 100644 index 0000000..9d1c6dd --- /dev/null +++ b/src/api/obj.rs @@ -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 { + 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()) +}