- Google OAuth login with JWT session cookies, per-user project isolation - Remote worker registration via WebSocket, execute_on_worker/list_workers agent tools - File browser UI in workflow view, file upload/download API - Deploy script switched to local build, added tori.euphon.cloud ingress
259 lines
7.5 KiB
Rust
259 lines
7.5 KiB
Rust
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<PathBuf, Response> {
|
|
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<Json<Vec<FileEntry>>, 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<String>) -> Result<Json<Vec<FileEntry>>, 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<String>,
|
|
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<RenameInput>,
|
|
) -> 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<MkdirInput>,
|
|
) -> 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<String>,
|
|
Json(input): Json<MkdirInput>,
|
|
) -> 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(),
|
|
}
|
|
}
|