leaf/examples/file_browser_demo/mock-server/src/main.rs
2025-08-05 15:02:23 +02:00

566 lines
18 KiB
Rust

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<u64>,
modified: Option<String>,
hash: Option<String>,
}
/// API response for directory listing
#[derive(Debug, Serialize)]
struct ListResponse {
contents: Vec<FileItem>,
}
/// 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<bool>,
}
/// Mock server state
#[derive(Clone)]
struct AppState {
base_dir: PathBuf,
// Simple upload tracking: upload_id -> (filename, file_path)
uploads: Arc<Mutex<HashMap<String, (String, PathBuf)>>>,
}
impl AppState {
fn new() -> anyhow::Result<Self> {
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<PathBuf> {
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<FileItem> {
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::<Utc>::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<ListQuery>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl IntoResponse {
list_directory_impl("".to_string(), params, state).await
}
/// List directory contents with path
/// GET /files/list/<path>
async fn list_directory(
Path(path): Path<String>,
Query(params): Query<ListQuery>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> 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/<path>
async fn create_directory(
Path(path): Path<String>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> 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/<path>
async fn delete_item(
Path(path): Path<String>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> 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/<path> (for specific directory)
async fn create_upload(
headers: HeaderMap,
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl IntoResponse {
create_upload_impl(headers, state, None).await
}
/// Handle TUS upload creation with path
/// POST /files/upload/<path>
async fn create_upload_with_path(
Path(path): Path<String>,
headers: HeaderMap,
axum::extract::State(state): axum::extract::State<AppState>,
) -> 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<String>,
) -> impl IntoResponse {
let upload_id = uuid::Uuid::new_v4().to_string();
// Get filename from Upload-Metadata header (base64 encoded)
// TUS format: "filename <base64-encoded-filename>,type <base64-encoded-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 <base64>,type <base64>"
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/<upload_id>
async fn tus_upload_chunk(
Path(upload_id): Path<String>,
axum::extract::State(state): axum::extract::State<AppState>,
_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/<path>
async fn download_file(
Path(path): Path<String>,
axum::extract::State(state): axum::extract::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: "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/<path>": "List directory contents",
"POST /files/dirs/<path>": "Create directory",
"DELETE /files/delete/<path>": "Delete file/directory",
"POST /files/upload": "Upload file (TUS protocol)",
"PATCH /files/upload/<id>": "Upload file chunk",
"GET /files/download/<path>": "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/<path> - List directory contents");
info!(" POST /files/dirs/<path> - Create directory");
info!(" DELETE /files/delete/<path> - Delete file/directory");
info!(" POST /files/upload - Upload file (TUS)");
info!(" GET /files/download/<path> - Download file");
info!(" GET /health - Health check");
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}