use std::path::PathBuf; use axum::{ extract::{Multipart, Path}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router, }; use serde::Serialize; fn workspace_root() -> &'static str { if std::path::Path::new("/app/data/workspaces").is_dir() { "/app/data/workspaces" } else { "data/workspaces" } } fn resolve_path(project_id: &str, rel: &str) -> Result { let base = PathBuf::from(workspace_root()).join(project_id); let full = base.join(rel); // Prevent path traversal if rel.contains("..") { return Err((StatusCode::BAD_REQUEST, "Invalid path").into_response()); } Ok(full) } #[derive(Serialize)] struct FileEntry { name: String, is_dir: bool, size: u64, } pub fn router() -> Router { Router::new() .route( "/projects/{id}/files", get(list_root).post(upload_root).patch(mkdir_root), ) .route( "/projects/{id}/files/{*path}", get(get_file) .post(upload_file) .put(rename_file) .delete(delete_file) .patch(mkdir), ) } async fn list_dir(dir: PathBuf) -> Result>, Response> { let mut entries = Vec::new(); // Return empty list if directory doesn't exist yet let mut rd = match tokio::fs::read_dir(&dir).await { Ok(rd) => rd, Err(_) => return Ok(Json(entries)), }; while let Ok(Some(e)) = rd.next_entry().await { let meta = match e.metadata().await { Ok(m) => m, Err(_) => continue, }; entries.push(FileEntry { name: e.file_name().to_string_lossy().to_string(), is_dir: meta.is_dir(), size: meta.len(), }); } entries.sort_by(|a, b| { b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)) }); Ok(Json(entries)) } async fn list_root(Path(project_id): Path) -> Result>, Response> { let dir = resolve_path(&project_id, "")?; list_dir(dir).await } async fn get_file( Path((project_id, file_path)): Path<(String, String)>, ) -> Response { let full = match resolve_path(&project_id, &file_path) { Ok(p) => p, Err(e) => return e, }; // If it's a directory, list contents if full.is_dir() { return match list_dir(full).await { Ok(j) => j.into_response(), Err(e) => e, }; } // Otherwise serve the file match tokio::fs::read(&full).await { Ok(bytes) => { let mime = mime_guess::from_path(&full) .first_or_octet_stream() .to_string(); let filename = full .file_name() .and_then(|n| n.to_str()) .unwrap_or("file"); ( [ (axum::http::header::CONTENT_TYPE, mime), ( axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename), ), ], bytes, ) .into_response() } Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), } } async fn do_upload(project_id: &str, rel_dir: &str, mut multipart: Multipart) -> Response { let dir = match resolve_path(project_id, rel_dir) { Ok(p) => p, Err(e) => return e, }; if let Err(e) = tokio::fs::create_dir_all(&dir).await { return (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response(); } let mut count = 0u32; while let Ok(Some(field)) = multipart.next_field().await { let filename: String = match field.file_name() { Some(f) => f.to_string(), None => continue, }; if filename.contains("..") || filename.contains('/') { return (StatusCode::BAD_REQUEST, "Invalid filename").into_response(); } let data = match field.bytes().await { Ok(d) => d, Err(e) => return (StatusCode::BAD_REQUEST, format!("Read error: {}", e)).into_response(), }; let dest = dir.join(&filename); if let Err(e) = tokio::fs::write(&dest, &data).await { return (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)).into_response(); } count += 1; } Json(serde_json::json!({ "uploaded": count })).into_response() } async fn upload_root( Path(project_id): Path, multipart: Multipart, ) -> Response { do_upload(&project_id, "", multipart).await } async fn upload_file( Path((project_id, file_path)): Path<(String, String)>, multipart: Multipart, ) -> Response { do_upload(&project_id, &file_path, multipart).await } #[derive(serde::Deserialize)] struct RenameInput { new_name: String, } async fn rename_file( Path((project_id, file_path)): Path<(String, String)>, Json(input): Json, ) -> Response { if input.new_name.contains("..") || input.new_name.contains('/') { return (StatusCode::BAD_REQUEST, "Invalid new name").into_response(); } let src = match resolve_path(&project_id, &file_path) { Ok(p) => p, Err(e) => return e, }; let dst = src .parent() .unwrap_or(&src) .join(&input.new_name); match tokio::fs::rename(&src, &dst).await { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Rename failed: {}", e)).into_response(), } } #[derive(serde::Deserialize)] struct MkdirInput { name: String, } async fn mkdir( Path((project_id, file_path)): Path<(String, String)>, Json(input): Json, ) -> Response { if input.name.contains("..") || input.name.contains('/') { return (StatusCode::BAD_REQUEST, "Invalid directory name").into_response(); } let parent = match resolve_path(&project_id, &file_path) { Ok(p) => p, Err(e) => return e, }; let dir = parent.join(&input.name); match tokio::fs::create_dir_all(&dir).await { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response(), } } async fn mkdir_root( Path(project_id): Path, Json(input): Json, ) -> Response { if input.name.contains("..") || input.name.contains('/') { return (StatusCode::BAD_REQUEST, "Invalid directory name").into_response(); } let parent = match resolve_path(&project_id, "") { Ok(p) => p, Err(e) => return e, }; let dir = parent.join(&input.name); match tokio::fs::create_dir_all(&dir).await { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response(), } } async fn delete_file( Path((project_id, file_path)): Path<(String, String)>, ) -> Response { let full = match resolve_path(&project_id, &file_path) { Ok(p) => p, Err(e) => return e, }; let result = if full.is_dir() { tokio::fs::remove_dir_all(&full).await } else { tokio::fs::remove_file(&full).await }; match result { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete failed: {}", e)).into_response(), } }