use anyhow::anyhow;
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;
use tracing::error;
use crate::AppState;
use crate::fs;
use crate::tmux;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/files", get(list_files).post(upload_file).delete(delete_file))
.route("/files/download", get(download_file))
.route("/files/read", get(read_file))
.route("/files/write", post(write_file))
.route("/files/mkdir", post(mkdir))
.route("/files/rename", post(rename))
.route("/files/move", post(move_files))
.route("/files/copy", post(copy_files))
.route("/files/search", get(search_files))
}
#[derive(Deserialize)]
struct FileQuery {
path: Option<String>,
workspace: Option<String>, session: Option<String>,
workspace_id: Option<String>, sort: Option<String>,
order: Option<String>,
}
#[derive(Deserialize)]
struct SearchQuery {
q: String,
path: Option<String>,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct RenameRequest {
path: String,
#[serde(rename = "newName")]
new_name: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct MoveRequest {
paths: Vec<String>,
destination: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct CopyRequest {
paths: Vec<String>,
destination: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct WriteRequest {
content: String,
}
pub async fn resolve_project_root(state: &AppState, project_id: &str) -> Option<String> {
sqlx::query_as::<_, (String,)>("SELECT path FROM projects WHERE id = ?")
.bind(project_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.map(|(p,)| p)
}
fn parse_sort(sort: Option<&str>, order: Option<&str>) -> (fs::SortKey, bool) {
let key = match sort {
Some("mtime") => fs::SortKey::Mtime,
Some("size") => fs::SortKey::Size,
_ => fs::SortKey::Name,
};
let desc = order == Some("desc");
(key, desc)
}
pub async fn resolve_session_base(state: &AppState, session_id: &str) -> Option<(String, String)> {
let row: (String, Option<String>, String) = sqlx::query_as(
"SELECT runtime_kind, tmux_session_name, workspace_path FROM sessions WHERE id = ?",
)
.bind(session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()?;
let (runtime_kind, tmux_name_opt, workspace_path) = row;
if runtime_kind == "acp" || tmux_name_opt.is_none() {
tracing::debug!(
"session {} is non-tmux (runtime_kind={}), using workspace_path={} as FileManager cwd",
session_id,
runtime_kind,
workspace_path
);
return Some((workspace_path, String::new()));
}
let tmux_name = tmux_name_opt.expect("checked above");
match tmux::pane_cwd(&tmux_name).await {
Ok(cwd) => Some((cwd, tmux_name)),
Err(e) => {
tracing::warn!(
"tmux session '{}' unavailable ({}), attempting re-create",
tmux_name,
e
);
let root = resolve_session_workspace_root(state, session_id)
.await
.unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()));
tmux::new_session(&tmux_name, &root, None).await.ok()?;
let cwd = tmux::pane_cwd(&tmux_name).await.ok()?;
tracing::info!("re-created tmux session '{}' at {}", tmux_name, cwd);
Some((cwd, tmux_name))
}
}
}
async fn resolve_session_workspace_root(state: &AppState, session_id: &str) -> Option<String> {
sqlx::query_as::<_, (String,)>("SELECT workspace_path FROM sessions WHERE id = ?")
.bind(session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.map(|(p,)| p)
}
pub async fn resolve_base_from_query(
state: &AppState,
session: Option<&str>,
workspace_id: Option<&str>,
project: Option<&str>,
) -> Option<(std::path::PathBuf, bool)> {
if let Some(sid) = session {
let (cwd, _) = resolve_session_base(state, sid).await?;
Some((std::path::PathBuf::from(cwd), true))
} else if let Some(wid) = workspace_id {
let pid = project.unwrap_or("default");
let root = resolve_workspace_root(state, wid, pid).await?;
Some((std::path::PathBuf::from(root), false))
} else {
let pid = project.unwrap_or("default");
let root = resolve_project_root(state, pid).await?;
Some((std::path::PathBuf::from(root), false))
}
}
async fn resolve_workspace_root(
state: &AppState,
workspace_id: &str,
project_id: &str,
) -> Option<String> {
use crate::workspaces;
let project_root = resolve_project_root(state, project_id).await?;
let project = crate::models::project::Project {
id: project_id.to_string(),
name: String::new(),
path: project_root,
target_id: None,
created_at: String::new(),
};
let wts = workspaces::list_workspaces(&project).await;
wts.into_iter().find(|w| w.id == workspace_id).map(|w| w.path)
}
async fn list_files(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> impl IntoResponse {
let (sort, desc) = parse_sort(q.sort.as_deref(), q.order.as_deref());
if let Some(session_id) = q.session.as_deref() {
let Some((cwd, _tmux_name)) = resolve_session_base(&state, session_id).await else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "session not found or tmux unavailable" })),
);
};
let rel_path = q.path.as_deref().unwrap_or("");
let base = std::path::Path::new(&cwd);
if !base.exists() {
return (
StatusCode::OK,
Json(
json!({ "files": [], "cwd": fs::display_path_str(&cwd), "is_outside_workspace": true }),
),
);
}
let is_outside =
if let Some(ws_root) = resolve_session_workspace_root(&state, session_id).await {
match (
std::path::Path::new(&cwd).canonicalize(),
std::path::Path::new(&ws_root).canonicalize(),
) {
(Ok(c), Ok(r)) => !c.starts_with(&r),
_ => !cwd.starts_with(&ws_root),
}
} else {
false
};
let list_base = if rel_path.is_empty() || rel_path == "." {
base.to_path_buf()
} else if std::path::Path::new(rel_path).is_absolute() {
std::path::Path::new(rel_path).to_path_buf()
} else {
base.join(rel_path)
};
let Ok(canonical) = list_base.canonicalize() else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "path not found" })));
};
match fs::list_dir(&canonical, "", sort, desc).await {
Ok(entries) => (
StatusCode::OK,
Json(
json!({ "files": entries, "cwd": fs::display_path(&canonical), "is_outside_workspace": is_outside }),
),
),
Err(e) => {
error!("list_files (session) failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else if let Some(workspace_id) = q.workspace_id.as_deref() {
let project_id = q.workspace.as_deref().unwrap_or("default");
let Some(root) = resolve_workspace_root(&state, workspace_id, project_id).await else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "workspace not found" })));
};
let base = std::path::Path::new(&root);
if !base.exists() {
return (
StatusCode::OK,
Json(
json!({ "files": [], "cwd": fs::display_path_str(&root), "is_outside_workspace": false }),
),
);
}
let rel_path = q.path.as_deref().unwrap_or("");
let list_base = if rel_path.is_empty() || rel_path == "." {
base.to_path_buf()
} else if std::path::Path::new(rel_path).is_absolute() {
std::path::Path::new(rel_path).to_path_buf()
} else {
base.join(rel_path)
};
let Ok(canonical) = list_base.canonicalize() else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "path not found" })));
};
let is_outside = match base.canonicalize() {
Ok(canonical_root) => !canonical.starts_with(&canonical_root),
Err(_) => false,
};
match fs::list_dir(&canonical, "", sort, desc).await {
Ok(entries) => (
StatusCode::OK,
Json(
json!({ "files": entries, "cwd": fs::display_path(&canonical), "is_outside_workspace": is_outside }),
),
),
Err(e) => {
error!("list_files (workspace) failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else if let Some(project_id) = q.workspace.as_deref() {
let rel_path = q.path.as_deref().unwrap_or("");
let Some(root) = resolve_project_root(&state, project_id).await else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "project not found" })));
};
let base = std::path::Path::new(&root);
if !base.exists() {
return (StatusCode::OK, Json(json!([])));
}
match fs::list_dir(base, rel_path, sort, desc).await {
Ok(entries) => (StatusCode::OK, Json(json!(entries))),
Err(e) => {
error!("list_files failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "session, workspace_id, or workspace parameter required" })),
)
}
}
async fn upload_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
let rel_path = q.path.as_deref().unwrap_or("");
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let mut uploaded = Vec::new();
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let file_name = field.file_name().unwrap_or("upload").to_string();
let data = match field.bytes().await {
Ok(d) => d,
Err(e) => {
error!("failed to read upload data: {}", e);
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "read failed" })));
}
};
let target_path = if rel_path.is_empty() || rel_path == "." {
file_name.clone()
} else {
format!("{}/{}", rel_path.trim_end_matches('/'), file_name)
};
if let Err(e) = fs::write_file(&base, &target_path, &data).await {
error!("upload write failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
uploaded.push(json!({
"name": file_name,
"path": target_path,
"size": data.len(),
}));
}
(StatusCode::OK, Json(json!(uploaded)))
}
async fn delete_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
match fs::delete_path(&base, path_str).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("delete failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn download_file(State(state): State<AppState>, Query(q): Query<FileQuery>) -> Response {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })))
.into_response();
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
)
.into_response();
};
let full_path = if std::path::Path::new(path_str).is_absolute() {
std::path::PathBuf::from(path_str)
} else {
match fs::sanitize_path(&base, path_str) {
Ok(p) => p,
Err(_) => {
return (StatusCode::FORBIDDEN, Json(json!({ "error": "invalid path" })))
.into_response();
}
}
};
let is_dir = tokio::fs::metadata(&full_path).await.map(|m| m.is_dir()).unwrap_or(false);
if is_dir {
let dir_name = full_path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let packed = match tokio::task::spawn_blocking(move || zip_directory(&full_path)).await {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
error!("zip directory failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response();
}
Err(e) => {
error!("zip task panicked: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "zip packing failed" })),
)
.into_response();
}
};
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}.zip\"", dir_name),
)
.body(Body::from(packed))
.unwrap();
}
let Ok(content) = tokio::fs::read(&full_path).await else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "file not found" }))).into_response();
};
let file_name = full_path.file_name().unwrap_or_default().to_string_lossy();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file_name))
.body(Body::from(content))
.unwrap()
}
fn zip_directory(dir: &std::path::Path) -> anyhow::Result<Vec<u8>> {
use std::io::{Read, Write};
use zip::write::SimpleFileOptions;
let mut buf: Vec<u8> = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let mut entries =
std::fs::read_dir(¤t).map_err(|e| anyhow!("read dir failed: {}", e))?;
while let Some(entry) = entries.next().transpose()? {
let path = entry.path();
let rel = path
.strip_prefix(dir.parent().unwrap_or(dir))
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let meta = std::fs::symlink_metadata(&path)?;
if meta.is_dir() {
zw.add_directory(format!("{}/", rel), options)?;
stack.push(path);
} else if meta.is_file() {
zw.start_file(rel, options)?;
let mut f = std::fs::File::open(&path)?;
let mut chunk = Vec::new();
f.read_to_end(&mut chunk)?;
zw.write_all(&chunk)?;
}
}
}
zw.finish()?;
}
Ok(buf)
}
async fn read_file(State(state): State<AppState>, Query(q): Query<FileQuery>) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let content = if std::path::Path::new(path_str).is_absolute() {
tokio::fs::read_to_string(path_str).await.map_err(|e| anyhow!(e))
} else {
fs::read_file(&base, path_str).await
};
match content {
Ok(content) => (StatusCode::OK, Json(json!({ "content": content }))),
Err(e) => {
error!("read_file failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn write_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<WriteRequest>,
) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let result: Result<(), anyhow::Error> = if std::path::Path::new(path_str).is_absolute() {
if let Some(parent) = std::path::Path::new(path_str).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(path_str, req.content.as_bytes()).await.map_err(|e| anyhow!(e))
} else {
fs::write_file(&base, path_str, req.content.as_bytes()).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("write_file failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn mkdir(
State(state): State<AppState>,
Json(req): Json<serde_json::Value>,
) -> impl IntoResponse {
let session_id = req.get("session").and_then(|v| v.as_str());
let workspace_id = req.get("workspace_id").and_then(|v| v.as_str());
let project_id = req.get("workspace").and_then(|v| v.as_str());
let path = req.get("path").and_then(|v| v.as_str()).unwrap_or("");
let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
let Some((base, _)) =
resolve_base_from_query(&state, session_id, workspace_id, project_id).await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let dir_path = if path.is_empty() || path == "." {
name.to_string()
} else {
format!("{}/{}", path.trim_end_matches('/'), name)
};
match fs::create_dir(&base, &dir_path).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("mkdir failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn rename(
State(state): State<AppState>,
Json(req): Json<RenameRequest>,
) -> impl IntoResponse {
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let old_path = std::path::Path::new(&req.path);
let new_rel = match old_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
format!("{}/{}", parent.to_string_lossy().trim_end_matches('/'), req.new_name)
}
_ => req.new_name.clone(),
};
match fs::move_path(&base, &req.path, &new_rel).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("rename failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn move_files(
State(state): State<AppState>,
Json(req): Json<MoveRequest>,
) -> impl IntoResponse {
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
for p in &req.paths {
let file_name = std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let dest = format!("{}/{}", req.destination.trim_end_matches('/'), file_name);
if let Err(e) = fs::move_path(&base, p, &dest).await {
error!("move failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
}
(StatusCode::OK, Json(json!({ "ok": true })))
}
async fn copy_files(
State(state): State<AppState>,
Json(req): Json<CopyRequest>,
) -> impl IntoResponse {
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
match fs::copy_paths(&base, &req.paths, &req.destination).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("copy failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn search_files(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> impl IntoResponse {
let rel_path = q.path.as_deref().unwrap_or("");
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
match fs::search_files(&base, rel_path, &q.q).await {
Ok(entries) => (StatusCode::OK, Json(json!(entries))),
Err(e) => {
error!("search failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
#[cfg(test)]
mod zip_tests {
use super::zip_directory;
use std::io::Read;
use std::path::Path;
#[test]
fn packs_directory_into_valid_zip() {
let dir = std::env::temp_dir().join("ot_ziptest_mod");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), b"hello").unwrap();
std::fs::write(dir.join("sub").join("b.txt"), b"world").unwrap();
let bytes = zip_directory(&dir).expect("zip should succeed");
assert!(!bytes.is_empty());
let mut cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(&mut cursor).expect("valid zip archive");
let mut names = Vec::new();
for i in 0..archive.len() {
let mut f = archive.by_index(i).unwrap();
let name = f.name().to_string();
names.push(name.clone());
if name.ends_with("a.txt") {
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "hello");
}
if name.ends_with("b.txt") {
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "world");
}
}
assert!(names.iter().any(|n| n.ends_with("a.txt")), "a.txt present: {:?}", names);
assert!(names.iter().any(|n| n.ends_with("sub/b.txt")), "sub/b.txt present: {:?}", names);
assert!(
names.iter().any(|n| n.ends_with("ot_ziptest_mod/") || n.contains("ot_ziptest_mod")),
"top folder preserved: {:?}",
names
);
let _ = std::fs::remove_dir_all(&dir);
}
#[allow(dead_code)]
fn _assert_path(_: &Path) {}
}