use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use super::state::AppState;
const MAX_FILE_SIZE: u64 = 100 * 1024;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/files", get(list_directory))
.route("/api/files/{*path}", get(read_file))
}
#[derive(Deserialize)]
struct ListQuery {
path: Option<String>,
}
#[derive(Serialize)]
struct FileEntry {
name: String,
path: String,
is_dir: bool,
size: u64,
}
async fn list_directory(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<FileEntry>>, (StatusCode, String)> {
let base = state.working_dir().await;
let target = match &query.path {
Some(p) if !p.is_empty() => resolve_safe_path(&base, p)?,
_ => PathBuf::from(&base),
};
let mut reader = tokio::fs::read_dir(&target)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("Cannot read directory: {e}")))?;
let mut entries = Vec::new();
while let Some(entry) = reader.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read error: {e}"),
)
})? {
let metadata = entry.metadata().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Metadata error: {e}"),
)
})?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let absolute = entry.path();
let relative = absolute
.strip_prefix(&base)
.unwrap_or(&absolute)
.to_string_lossy()
.to_string();
entries.push(FileEntry {
name,
path: relative,
is_dir: metadata.is_dir(),
size: metadata.len(),
});
}
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(Json(entries))
}
#[derive(Serialize)]
struct FileContent {
path: String,
content: String,
lines: usize,
}
async fn read_file(
State(state): State<Arc<AppState>>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> Result<Json<FileContent>, (StatusCode, String)> {
let base = state.working_dir().await;
let target = resolve_safe_path(&base, &path)?;
let metadata = tokio::fs::metadata(&target)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("File not found: {e}")))?;
if metadata.is_dir() {
return Err((StatusCode::BAD_REQUEST, "Path is a directory".into()));
}
if metadata.len() > MAX_FILE_SIZE {
return Err((
StatusCode::BAD_REQUEST,
format!(
"File too large ({} bytes, max {})",
metadata.len(),
MAX_FILE_SIZE
),
));
}
let content = tokio::fs::read_to_string(&target).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read error: {e}"),
)
})?;
let lines = content.lines().count();
let relative = target
.strip_prefix(&base)
.unwrap_or(&target)
.to_string_lossy()
.to_string();
Ok(Json(FileContent {
path: relative,
content,
lines,
}))
}
fn resolve_safe_path(base: &str, user_path: &str) -> Result<PathBuf, (StatusCode, String)> {
let canonical_base = std::fs::canonicalize(base).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Base path error: {e}"),
)
})?;
let normalized = normalize_path_lexical(&canonical_base.join(user_path));
if !normalized.starts_with(&canonical_base) {
return Err((StatusCode::FORBIDDEN, "Access denied".into()));
}
Ok(normalized)
}
fn normalize_path_lexical(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
c => out.push(c),
}
}
out
}