mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-04 11:05:53 +00:00
464 lines
12 KiB
Rust
464 lines
12 KiB
Rust
use axum::{
|
|
extract::Query,
|
|
http::{header, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use base64::Engine;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use tokio::fs;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FileQuery {
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileMetadata {
|
|
pub name: String,
|
|
pub path: String,
|
|
pub size: u64,
|
|
pub is_dir: bool,
|
|
pub is_file: bool,
|
|
pub is_symlink: bool,
|
|
pub readonly: bool,
|
|
pub hidden: bool,
|
|
pub created: Option<String>,
|
|
pub modified: Option<String>,
|
|
pub accessed: Option<String>,
|
|
#[cfg(unix)]
|
|
pub permissions: Option<String>,
|
|
pub mime_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct MoveRequest {
|
|
pub from: String,
|
|
pub to: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CopyRequest {
|
|
pub from: String,
|
|
pub to: String,
|
|
pub overwrite: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct WriteFileRequest {
|
|
pub path: String,
|
|
pub content: String,
|
|
pub encoding: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OperationResult {
|
|
pub success: bool,
|
|
pub message: String,
|
|
}
|
|
|
|
/// Expand tilde to home directory
|
|
fn expand_path(path: &str) -> Result<PathBuf, StatusCode> {
|
|
if path.starts_with('~') {
|
|
let home = dirs::home_dir().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
Ok(home.join(path.strip_prefix("~/").unwrap_or("")))
|
|
} else {
|
|
Ok(PathBuf::from(path))
|
|
}
|
|
}
|
|
|
|
/// Get detailed file metadata
|
|
pub async fn get_file_info(
|
|
Query(params): Query<FileQuery>,
|
|
) -> Result<Json<FileMetadata>, StatusCode> {
|
|
let path = expand_path(¶ms.path)?;
|
|
|
|
let metadata = fs::metadata(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
let name = path
|
|
.file_name().map_or_else(|| path.to_string_lossy().to_string(), |n| n.to_string_lossy().to_string());
|
|
|
|
let is_symlink = fs::symlink_metadata(&path)
|
|
.await
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
|
|
let hidden = name.starts_with('.');
|
|
|
|
let created = metadata
|
|
.created()
|
|
.map(|t| {
|
|
let datetime: chrono::DateTime<chrono::Utc> = t.into();
|
|
datetime.to_rfc3339()
|
|
})
|
|
.ok();
|
|
|
|
let modified = metadata
|
|
.modified()
|
|
.map(|t| {
|
|
let datetime: chrono::DateTime<chrono::Utc> = t.into();
|
|
datetime.to_rfc3339()
|
|
})
|
|
.ok();
|
|
|
|
let accessed = metadata
|
|
.accessed()
|
|
.map(|t| {
|
|
let datetime: chrono::DateTime<chrono::Utc> = t.into();
|
|
datetime.to_rfc3339()
|
|
})
|
|
.ok();
|
|
|
|
#[cfg(unix)]
|
|
let permissions = {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
Some(format!("{:o}", metadata.permissions().mode() & 0o777))
|
|
};
|
|
|
|
let mime_type = if metadata.is_file() {
|
|
// Simple MIME type detection based on extension
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
|
|
Some(
|
|
match ext {
|
|
"txt" => "text/plain",
|
|
"html" | "htm" => "text/html",
|
|
"css" => "text/css",
|
|
"js" => "application/javascript",
|
|
"json" => "application/json",
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"gif" => "image/gif",
|
|
"pdf" => "application/pdf",
|
|
"zip" => "application/zip",
|
|
_ => "application/octet-stream",
|
|
}
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(Json(FileMetadata {
|
|
name,
|
|
path: path.to_string_lossy().to_string(),
|
|
size: metadata.len(),
|
|
is_dir: metadata.is_dir(),
|
|
is_file: metadata.is_file(),
|
|
is_symlink,
|
|
readonly: metadata.permissions().readonly(),
|
|
hidden,
|
|
created,
|
|
modified,
|
|
accessed,
|
|
#[cfg(unix)]
|
|
permissions,
|
|
#[cfg(not(unix))]
|
|
permissions: None,
|
|
mime_type,
|
|
}))
|
|
}
|
|
|
|
/// Read file contents
|
|
pub async fn read_file(Query(params): Query<FileQuery>) -> Result<Response, StatusCode> {
|
|
let path = expand_path(¶ms.path)?;
|
|
|
|
// Check if file exists and is a file
|
|
let metadata = fs::metadata(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
if !metadata.is_file() {
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// Read file contents
|
|
let mut file = fs::File::open(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
let mut contents = Vec::new();
|
|
file.read_to_end(&mut contents)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
// Determine content type
|
|
let content_type = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.and_then(|ext| match ext {
|
|
"txt" => Some("text/plain"),
|
|
"html" | "htm" => Some("text/html"),
|
|
"css" => Some("text/css"),
|
|
"js" => Some("application/javascript"),
|
|
"json" => Some("application/json"),
|
|
"png" => Some("image/png"),
|
|
"jpg" | "jpeg" => Some("image/jpeg"),
|
|
"gif" => Some("image/gif"),
|
|
"pdf" => Some("application/pdf"),
|
|
_ => None,
|
|
})
|
|
.unwrap_or("application/octet-stream");
|
|
|
|
Ok(([(header::CONTENT_TYPE, content_type)], contents).into_response())
|
|
}
|
|
|
|
/// Write file contents
|
|
pub async fn write_file(
|
|
Json(req): Json<WriteFileRequest>,
|
|
) -> Result<Json<OperationResult>, StatusCode> {
|
|
let path = expand_path(&req.path)?;
|
|
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
|
|
// Write file
|
|
let content = if req.encoding.as_deref() == Some("base64") {
|
|
base64::engine::general_purpose::STANDARD
|
|
.decode(&req.content)
|
|
.map_err(|_| StatusCode::BAD_REQUEST)?
|
|
} else {
|
|
req.content.into_bytes()
|
|
};
|
|
|
|
fs::write(&path, content)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
Ok(Json(OperationResult {
|
|
success: true,
|
|
message: format!("File written successfully: {}", path.display()),
|
|
}))
|
|
}
|
|
|
|
/// Delete file or directory
|
|
pub async fn delete_file(
|
|
Query(params): Query<FileQuery>,
|
|
) -> Result<Json<OperationResult>, StatusCode> {
|
|
let path = expand_path(¶ms.path)?;
|
|
|
|
// Check if path exists
|
|
let metadata = fs::metadata(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
// Delete based on type
|
|
if metadata.is_dir() {
|
|
fs::remove_dir_all(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
} else {
|
|
fs::remove_file(&path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
|
|
Ok(Json(OperationResult {
|
|
success: true,
|
|
message: format!("Deleted: {}", path.display()),
|
|
}))
|
|
}
|
|
|
|
/// Move/rename file or directory
|
|
pub async fn move_file(Json(req): Json<MoveRequest>) -> Result<Json<OperationResult>, StatusCode> {
|
|
let from_path = expand_path(&req.from)?;
|
|
let to_path = expand_path(&req.to)?;
|
|
|
|
// Check if source exists
|
|
if !from_path.exists() {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// Check if destination already exists
|
|
if to_path.exists() {
|
|
return Err(StatusCode::CONFLICT);
|
|
}
|
|
|
|
// Ensure destination parent directory exists
|
|
if let Some(parent) = to_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
|
|
// Move the file/directory
|
|
fs::rename(&from_path, &to_path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
Ok(Json(OperationResult {
|
|
success: true,
|
|
message: format!(
|
|
"Moved from {} to {}",
|
|
from_path.display(),
|
|
to_path.display()
|
|
),
|
|
}))
|
|
}
|
|
|
|
/// Copy file or directory
|
|
pub async fn copy_file(Json(req): Json<CopyRequest>) -> Result<Json<OperationResult>, StatusCode> {
|
|
let from_path = expand_path(&req.from)?;
|
|
let to_path = expand_path(&req.to)?;
|
|
|
|
// Check if source exists
|
|
let metadata = fs::metadata(&from_path)
|
|
.await
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
// Check if destination already exists
|
|
if to_path.exists() && !req.overwrite.unwrap_or(false) {
|
|
return Err(StatusCode::CONFLICT);
|
|
}
|
|
|
|
// Ensure destination parent directory exists
|
|
if let Some(parent) = to_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
|
|
// Copy based on type
|
|
if metadata.is_file() {
|
|
fs::copy(&from_path, &to_path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
} else if metadata.is_dir() {
|
|
// Recursive directory copy
|
|
copy_dir_recursive(&from_path, &to_path).await?;
|
|
}
|
|
|
|
Ok(Json(OperationResult {
|
|
success: true,
|
|
message: format!(
|
|
"Copied from {} to {}",
|
|
from_path.display(),
|
|
to_path.display()
|
|
),
|
|
}))
|
|
}
|
|
|
|
/// Recursively copy a directory
|
|
async fn copy_dir_recursive(from: &PathBuf, to: &PathBuf) -> Result<(), StatusCode> {
|
|
fs::create_dir_all(to)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
let mut entries = fs::read_dir(from)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
while let Some(entry) = entries
|
|
.next_entry()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
{
|
|
let from_path = entry.path();
|
|
let to_path = to.join(entry.file_name());
|
|
|
|
let metadata = entry
|
|
.metadata()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
if metadata.is_file() {
|
|
fs::copy(&from_path, &to_path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
} else if metadata.is_dir() {
|
|
Box::pin(copy_dir_recursive(&from_path, &to_path)).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Search for files matching a pattern
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SearchQuery {
|
|
pub path: String,
|
|
pub pattern: String,
|
|
pub max_depth: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SearchResult {
|
|
pub path: String,
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
pub size: u64,
|
|
}
|
|
|
|
pub async fn search_files(
|
|
Query(params): Query<SearchQuery>,
|
|
) -> Result<Json<Vec<SearchResult>>, StatusCode> {
|
|
let base_path = expand_path(¶ms.path)?;
|
|
let pattern = params.pattern.to_lowercase();
|
|
let max_depth = params.max_depth.unwrap_or(5);
|
|
|
|
let mut results = Vec::new();
|
|
search_recursive(&base_path, &pattern, 0, max_depth, &mut results).await?;
|
|
|
|
Ok(Json(results))
|
|
}
|
|
|
|
async fn search_recursive(
|
|
path: &PathBuf,
|
|
pattern: &str,
|
|
depth: u32,
|
|
max_depth: u32,
|
|
results: &mut Vec<SearchResult>,
|
|
) -> Result<(), StatusCode> {
|
|
if depth > max_depth {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut entries = fs::read_dir(path)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
while let Some(entry) = entries
|
|
.next_entry()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
{
|
|
let entry_path = entry.path();
|
|
let file_name = entry.file_name().to_string_lossy().to_string();
|
|
|
|
if file_name.to_lowercase().contains(pattern) {
|
|
let metadata = entry
|
|
.metadata()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
results.push(SearchResult {
|
|
path: entry_path.to_string_lossy().to_string(),
|
|
name: file_name,
|
|
is_dir: metadata.is_dir(),
|
|
size: metadata.len(),
|
|
});
|
|
}
|
|
|
|
// Recurse into directories
|
|
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
|
|
Box::pin(search_recursive(
|
|
&entry_path,
|
|
pattern,
|
|
depth + 1,
|
|
max_depth,
|
|
results,
|
|
))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|