tori/src/api/files.rs
Fam Zheng 63f0582f54 feat: add Google OAuth, remote worker system, and file browser
- 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
2026-03-17 02:00:58 +00:00

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(),
}
}