use axum::{ extract::{DefaultBodyLimit, Path, Query}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Json, Response}, routing::{delete, get, post}, Router, }; use walkdir::WalkDir; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fs, path::{Path as StdPath, PathBuf}, sync::{Arc, Mutex}, }; use tower_http::cors::CorsLayer; use tracing::{info, warn}; /// File/Directory item information #[derive(Debug, Serialize, Deserialize)] struct FileItem { name: String, path: String, is_directory: bool, size: Option, modified: Option, hash: Option, } /// API response for directory listing #[derive(Debug, Serialize)] struct ListResponse { contents: Vec, } /// API response for errors #[derive(Debug, Serialize)] struct ErrorResponse { error: String, } /// API response for success messages #[derive(Debug, Serialize)] struct SuccessResponse { message: String, } /// Query parameters for listing #[derive(Debug, Deserialize)] struct ListQuery { recursive: Option, } /// Mock server state #[derive(Clone)] struct AppState { base_dir: PathBuf, // Simple upload tracking: upload_id -> (filename, file_path) uploads: Arc>>, } impl AppState { fn new() -> anyhow::Result { let base_dir = PathBuf::from("./mock_files"); // Create base directory if it doesn't exist fs::create_dir_all(&base_dir)?; // Create some sample files and directories create_sample_files(&base_dir)?; Ok(AppState { base_dir, uploads: Arc::new(Mutex::new(HashMap::new())), }) } /// Get a safe path within the base directory fn get_safe_path(&self, user_path: &str) -> Option { let user_path = if user_path.is_empty() || user_path == "." { "".to_string() } else { user_path.to_string() }; // Normalize path and prevent directory traversal let normalized = user_path.replace("..", "").replace("//", "/"); let safe_path = self.base_dir.join(normalized); // Ensure the path is within base directory if safe_path.starts_with(&self.base_dir) { Some(safe_path) } else { None } } } /// Create sample files and directories for demo fn create_sample_files(base_dir: &StdPath) -> anyhow::Result<()> { let sample_dirs = ["documents", "images", "projects"]; let sample_files = [ ("README.md", "# File Browser Demo\n\nThis is a sample file for testing the file browser component."), ("sample.txt", "This is a sample text file."), ("documents/report.md", "# Sample Report\n\nThis is a sample markdown report."), ("documents/notes.txt", "Sample notes file content."), ("images/placeholder.txt", "Placeholder for image files."), ("projects/project1.md", "# Project 1\n\nSample project documentation."), ("projects/config.json", r#"{"name": "sample-project", "version": "1.0.0"}"#), ]; // Create sample directories for dir in &sample_dirs { let dir_path = base_dir.join(dir); fs::create_dir_all(dir_path)?; } // Create sample files for (file_path, content) in &sample_files { let full_path = base_dir.join(file_path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent)?; } fs::write(full_path, content)?; } Ok(()) } /// Convert file metadata to FileItem fn file_to_item(path: &StdPath, base_dir: &StdPath) -> anyhow::Result { let metadata = fs::metadata(path)?; let name = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let relative_path = path.strip_prefix(base_dir) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| name.clone()); let modified = metadata.modified() .ok() .and_then(|time| DateTime::::from(time).format("%Y-%m-%d %H:%M:%S").to_string().into()); Ok(FileItem { name, path: relative_path, is_directory: metadata.is_dir(), size: if metadata.is_file() { Some(metadata.len()) } else { None }, modified, hash: None, }) } /// List directory contents (root) /// GET /files/list/ async fn list_root_directory( Query(params): Query, axum::extract::State(state): axum::extract::State, ) -> impl IntoResponse { list_directory_impl("".to_string(), params, state).await } /// List directory contents with path /// GET /files/list/ async fn list_directory( Path(path): Path, Query(params): Query, axum::extract::State(state): axum::extract::State, ) -> impl IntoResponse { list_directory_impl(path, params, state).await } /// Internal implementation for directory listing async fn list_directory_impl( path: String, params: ListQuery, state: AppState, ) -> impl IntoResponse { let safe_path = match state.get_safe_path(&path) { Some(p) => p, None => { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid path".to_string() }), ).into_response(); } }; if !safe_path.exists() || !safe_path.is_dir() { return ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Directory not found".to_string() }), ).into_response(); } let mut contents = Vec::new(); if params.recursive.unwrap_or(false) { // Recursive listing for entry in WalkDir::new(&safe_path) { if let Ok(entry) = entry { if entry.path() != safe_path { if let Ok(item) = file_to_item(entry.path(), &state.base_dir) { contents.push(item); } } } } } else { // Non-recursive listing if let Ok(entries) = fs::read_dir(&safe_path) { for entry in entries.flatten() { if let Ok(item) = file_to_item(&entry.path(), &state.base_dir) { contents.push(item); } } } } // Sort: directories first, then files, both alphabetically contents.sort_by(|a, b| { match (a.is_directory, b.is_directory) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), } }); Json(ListResponse { contents }).into_response() } /// Create directory /// POST /files/dirs/ async fn create_directory( Path(path): Path, axum::extract::State(state): axum::extract::State, ) -> Response { let safe_path = match state.get_safe_path(&path) { Some(p) => p, None => { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid path".to_string() }), ).into_response(); } }; match fs::create_dir_all(&safe_path) { Ok(_) => { info!("Created directory: {:?}", safe_path); ( StatusCode::OK, Json(SuccessResponse { message: "Directory created successfully".to_string() }), ).into_response() } Err(e) => { warn!("Failed to create directory {:?}: {}", safe_path, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to create directory".to_string() }), ).into_response() } } } /// Delete file or directory /// DELETE /files/delete/ async fn delete_item( Path(path): Path, axum::extract::State(state): axum::extract::State, ) -> Response { let safe_path = match state.get_safe_path(&path) { Some(p) => p, None => { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid path".to_string() }), ).into_response(); } }; if !safe_path.exists() { return ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "File or directory not found".to_string() }), ).into_response(); } let result = if safe_path.is_dir() { fs::remove_dir_all(&safe_path) } else { fs::remove_file(&safe_path) }; match result { Ok(_) => { info!("Deleted: {:?}", safe_path); ( StatusCode::OK, Json(SuccessResponse { message: "Deleted successfully".to_string() }), ).into_response() } Err(e) => { warn!("Failed to delete {:?}: {}", safe_path, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to delete".to_string() }), ).into_response() } } } /// Handle TUS upload creation /// POST /files/upload /// POST /files/upload/ (for specific directory) async fn create_upload( headers: HeaderMap, axum::extract::State(state): axum::extract::State, ) -> impl IntoResponse { create_upload_impl(headers, state, None).await } /// Handle TUS upload creation with path /// POST /files/upload/ async fn create_upload_with_path( Path(path): Path, headers: HeaderMap, axum::extract::State(state): axum::extract::State, ) -> impl IntoResponse { create_upload_impl(headers, state, Some(path)).await } /// Internal implementation for upload creation async fn create_upload_impl( headers: HeaderMap, state: AppState, target_path: Option, ) -> impl IntoResponse { let upload_id = uuid::Uuid::new_v4().to_string(); // Get filename from Upload-Metadata header (base64 encoded) // TUS format: "filename ,type " let filename = headers .get("upload-metadata") .and_then(|v| v.to_str().ok()) .and_then(|metadata| { info!("Upload metadata received: {}", metadata); // Parse TUS metadata format: "filename ,type " for pair in metadata.split(',') { let parts: Vec<&str> = pair.trim().split_whitespace().collect(); if parts.len() == 2 && parts[0] == "filename" { use base64::Engine; if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(parts[1]) { if let Ok(decoded_filename) = String::from_utf8(decoded_bytes) { info!("Extracted filename: {}", decoded_filename); return Some(decoded_filename); } } } } None }) .unwrap_or_else(|| { warn!("Could not extract filename from metadata, using fallback: upload_{}", upload_id); format!("upload_{}", upload_id) }); // Determine target directory - use provided path or current directory let target_dir = if let Some(path) = target_path { if path.is_empty() { state.base_dir.clone() } else { state.base_dir.join(&path) } } else { state.base_dir.clone() }; // Create target directory if it doesn't exist if let Err(e) = fs::create_dir_all(&target_dir) { warn!("Failed to create target directory: {}", e); } // Store upload metadata with preserved filename let upload_path = target_dir.join(&filename); // Store the upload info for later use if let Ok(mut uploads) = state.uploads.lock() { uploads.insert(upload_id.clone(), (filename.clone(), upload_path)); } let mut response_headers = HeaderMap::new(); response_headers.insert("Location", format!("/files/upload/{}", upload_id).parse().unwrap()); response_headers.insert("Tus-Resumable", "1.0.0".parse().unwrap()); info!("Created upload with ID: {} for file: {}", upload_id, filename); (StatusCode::CREATED, response_headers, "") } /// Handle TUS upload data /// PATCH /files/upload/ async fn tus_upload_chunk( Path(upload_id): Path, axum::extract::State(state): axum::extract::State, _headers: HeaderMap, body: axum::body::Bytes, ) -> impl IntoResponse { // Get upload info from tracking let upload_info = { if let Ok(uploads) = state.uploads.lock() { uploads.get(&upload_id).cloned() } else { None } }; let (filename, file_path) = match upload_info { Some(info) => info, None => { warn!("Upload ID not found: {}", upload_id); return (StatusCode::NOT_FOUND, HeaderMap::new(), "").into_response(); } }; // Write the file data to disk match std::fs::write(&file_path, &body) { Ok(_) => { info!("Successfully saved file: {} ({} bytes)", filename, body.len()); // Clean up upload tracking if let Ok(mut uploads) = state.uploads.lock() { uploads.remove(&upload_id); } let mut response_headers = HeaderMap::new(); response_headers.insert("Tus-Resumable", "1.0.0".parse().unwrap()); response_headers.insert("Upload-Offset", body.len().to_string().parse().unwrap()); (StatusCode::NO_CONTENT, response_headers, "").into_response() } Err(e) => { warn!("Failed to save file {}: {}", filename, e); (StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new(), "").into_response() } } } /// Download file /// GET /files/download/ async fn download_file( Path(path): Path, axum::extract::State(state): axum::extract::State, ) -> impl IntoResponse { let safe_path = match state.get_safe_path(&path) { Some(p) => p, None => { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid path".to_string() }), ).into_response(); } }; if !safe_path.exists() || safe_path.is_dir() { return ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "File not found".to_string() }), ).into_response(); } match fs::read(&safe_path) { Ok(contents) => { let mut headers = HeaderMap::new(); headers.insert( "Content-Disposition", format!("attachment; filename=\"{}\"", safe_path.file_name().unwrap_or_default().to_string_lossy()) .parse().unwrap() ); (StatusCode::OK, headers, contents).into_response() } Err(e) => { warn!("Failed to read file {:?}: {}", safe_path, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to read file".to_string() }), ).into_response() } } } /// Health check endpoint async fn health_check() -> impl IntoResponse { Json(serde_json::json!({ "status": "ok", "message": "Mock file server is running" })) } /// Root endpoint with API info async fn root() -> impl IntoResponse { Json(serde_json::json!({ "name": "Mock File Server", "description": "A Rust mock server for testing the file browser component", "endpoints": { "GET /files/list/": "List directory contents", "POST /files/dirs/": "Create directory", "DELETE /files/delete/": "Delete file/directory", "POST /files/upload": "Upload file (TUS protocol)", "PATCH /files/upload/": "Upload file chunk", "GET /files/download/": "Download file", "GET /health": "Health check" } })) } #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialize tracing tracing_subscriber::fmt::init(); // Initialize app state let state = AppState::new()?; info!("Base directory: {:?}", state.base_dir); // Build the router let app = Router::new() .route("/", get(root)) .route("/health", get(health_check)) .route("/files/list/*path", get(list_directory)) .route("/files/list/", get(list_root_directory)) .route("/files/dirs/*path", post(create_directory)) .route("/files/delete/*path", delete(delete_item)) .route("/files/upload", post(create_upload)) .route("/files/upload/to/*path", post(create_upload_with_path)) .route("/files/upload/:upload_id", axum::routing::patch(tus_upload_chunk)) .route("/files/download/*path", get(download_file)) .layer(DefaultBodyLimit::max(500 * 1024 * 1024)) // 500MB limit for large file uploads .layer(CorsLayer::permissive()) .with_state(state); // Start the server let port = std::env::var("PORT").unwrap_or_else(|_| "3001".to_string()); let addr = format!("0.0.0.0:{}", port); info!("🚀 Mock File Server starting on http://{}", addr); info!("📋 Available endpoints:"); info!(" GET /files/list/ - List directory contents"); info!(" POST /files/dirs/ - Create directory"); info!(" DELETE /files/delete/ - Delete file/directory"); info!(" POST /files/upload - Upload file (TUS)"); info!(" GET /files/download/ - Download file"); info!(" GET /health - Health check"); let listener = tokio::net::TcpListener::bind(&addr).await?; axum::serve(listener, app).await?; Ok(()) }