Buckets:
| use std::fs; | |
| use std::io::{self, Read, Write}; | |
| use std::path::{Path, PathBuf}; | |
| use std::time::UNIX_EPOCH; | |
| use actix_cors::Cors; | |
| use actix_multipart::Multipart; | |
| use actix_web::{delete, get, middleware, post, put, web, App, HttpResponse, HttpServer, Responder}; | |
| use futures_util::StreamExt; | |
| use serde::{Deserialize, Serialize}; | |
| const ROOT_DIR: &str = "/data"; | |
| fn safe_path(rel: &str) -> Option<PathBuf> { | |
| let root = PathBuf::from(ROOT_DIR).canonicalize().ok()?; | |
| let joined = root.join(rel.trim_start_matches('/')); | |
| let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone()); | |
| // Escape prevention: ensure path stays within ROOT_DIR | |
| if canonical.starts_with(&root) || joined.starts_with(&root) { | |
| Some(joined) | |
| } else { | |
| None | |
| } | |
| } | |
| struct FileEntry { | |
| name: String, | |
| path: String, | |
| is_dir: bool, | |
| size: u64, | |
| modified: u64, | |
| mime: String, | |
| } | |
| struct DirListing { | |
| path: String, | |
| entries: Vec<FileEntry>, | |
| } | |
| struct SystemStats { | |
| total_files: u64, | |
| total_dirs: u64, | |
| total_size: u64, | |
| ram_used_mb: u64, | |
| ram_total_mb: u64, | |
| root_path: String, | |
| } | |
| struct PathQuery { | |
| path: Option<String>, | |
| } | |
| struct SearchQuery { | |
| q: String, | |
| path: Option<String>, | |
| } | |
| struct WriteBody { | |
| content: String, | |
| } | |
| struct CreateDirBody { | |
| path: String, | |
| name: String, | |
| } | |
| fn mime_type(name: &str) -> &'static str { | |
| let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| match ext.as_str() { | |
| "jpg" | "jpeg" => "image/jpeg", | |
| "png" => "image/png", | |
| "gif" => "image/gif", | |
| "webp" => "image/webp", | |
| "svg" => "image/svg+xml", | |
| "mp4" => "video/mp4", | |
| "webm" => "video/webm", | |
| "mp3" => "audio/mpeg", | |
| "ogg" => "audio/ogg", | |
| "wav" => "audio/wav", | |
| "html" | "htm" => "text/html", | |
| "css" => "text/css", | |
| "js" | "mjs" => "text/javascript", | |
| "ts" => "text/typescript", | |
| "json" => "application/json", | |
| "md" => "text/markdown", | |
| "txt" => "text/plain", | |
| "rs" => "text/x-rust", | |
| "py" => "text/x-python", | |
| "sh" => "text/x-sh", | |
| "toml" | "yaml" | "yml" => "text/plain", | |
| "pdf" => "application/pdf", | |
| "zip" => "application/zip", | |
| _ => "application/octet-stream", | |
| } | |
| } | |
| fn count_recursive(path: &Path) -> (u64, u64, u64) { | |
| let mut files = 0u64; | |
| let mut dirs = 0u64; | |
| let mut size = 0u64; | |
| if let Ok(entries) = fs::read_dir(path) { | |
| for entry in entries.flatten() { | |
| if let Ok(meta) = entry.metadata() { | |
| if meta.is_dir() { | |
| dirs += 1; | |
| let (f, d, s) = count_recursive(&entry.path()); | |
| files += f; | |
| dirs += d; | |
| size += s; | |
| } else { | |
| files += 1; | |
| size += meta.len(); | |
| } | |
| } | |
| } | |
| } | |
| (files, dirs, size) | |
| } | |
| fn search_recursive(path: &Path, query: &str, results: &mut Vec<FileEntry>, root: &Path) { | |
| if let Ok(entries) = fs::read_dir(path) { | |
| for entry in entries.flatten() { | |
| let name = entry.file_name().to_string_lossy().to_string(); | |
| if name.to_lowercase().contains(&query.to_lowercase()) { | |
| if let Ok(meta) = entry.metadata() { | |
| let rel = entry | |
| .path() | |
| .strip_prefix(root) | |
| .map(|p| format!("/{}", p.display())) | |
| .unwrap_or_default(); | |
| results.push(FileEntry { | |
| mime: if meta.is_dir() { | |
| "inode/directory".to_string() | |
| } else { | |
| mime_type(&name).to_string() | |
| }, | |
| name, | |
| path: rel, | |
| is_dir: meta.is_dir(), | |
| size: meta.len(), | |
| modified: meta | |
| .modified() | |
| .ok() | |
| .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) | |
| .map(|d| d.as_secs()) | |
| .unwrap_or(0), | |
| }); | |
| } | |
| } | |
| if entry.metadata().map(|m| m.is_dir()).unwrap_or(false) { | |
| search_recursive(&entry.path(), query, results, root); | |
| } | |
| } | |
| } | |
| } | |
| async fn list_dir(query: web::Query<PathQuery>) -> impl Responder { | |
| let rel = query.path.clone().unwrap_or_else(|| "/".to_string()); | |
| let Some(abs) = safe_path(&rel) else { | |
| return HttpResponse::Forbidden().json(serde_json::json!({"error": "Access denied"})); | |
| }; | |
| if !abs.exists() { | |
| return HttpResponse::NotFound().json(serde_json::json!({"error": "Not found"})); | |
| } | |
| let root = PathBuf::from(ROOT_DIR); | |
| let mut entries = Vec::new(); | |
| if let Ok(read) = fs::read_dir(&abs) { | |
| let mut raw: Vec<_> = read.flatten().collect(); | |
| raw.sort_by(|a, b| { | |
| let a_dir = a.metadata().map(|m| m.is_dir()).unwrap_or(false); | |
| let b_dir = b.metadata().map(|m| m.is_dir()).unwrap_or(false); | |
| b_dir.cmp(&a_dir).then(a.file_name().cmp(&b.file_name())) | |
| }); | |
| for entry in raw { | |
| let name = entry.file_name().to_string_lossy().to_string(); | |
| if let Ok(meta) = entry.metadata() { | |
| let rel_path = entry | |
| .path() | |
| .strip_prefix(&root) | |
| .map(|p| format!("/{}", p.display())) | |
| .unwrap_or_else(|_| format!("/{}", name)); | |
| entries.push(FileEntry { | |
| mime: if meta.is_dir() { | |
| "inode/directory".to_string() | |
| } else { | |
| mime_type(&name).to_string() | |
| }, | |
| name, | |
| path: rel_path, | |
| is_dir: meta.is_dir(), | |
| size: meta.len(), | |
| modified: meta | |
| .modified() | |
| .ok() | |
| .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) | |
| .map(|d| d.as_secs()) | |
| .unwrap_or(0), | |
| }); | |
| } | |
| } | |
| } | |
| let display_path = abs | |
| .strip_prefix(&root) | |
| .map(|p| format!("/{}", p.display())) | |
| .unwrap_or_else(|_| "/".to_string()); | |
| HttpResponse::Ok().json(DirListing { | |
| path: if display_path.is_empty() { "/".to_string() } else { display_path }, | |
| entries, | |
| }) | |
| } | |
| async fn read_file(query: web::Query<PathQuery>) -> impl Responder { | |
| let rel = query.path.clone().unwrap_or_default(); | |
| let Some(abs) = safe_path(&rel) else { | |
| return HttpResponse::Forbidden().body("Access denied"); | |
| }; | |
| match fs::read(&abs) { | |
| Ok(bytes) => { | |
| let name = abs.file_name().unwrap_or_default().to_string_lossy().to_string(); | |
| let mime = mime_type(&name); | |
| HttpResponse::Ok().content_type(mime).body(bytes) | |
| } | |
| Err(e) => HttpResponse::InternalServerError().body(e.to_string()), | |
| } | |
| } | |
| async fn write_file( | |
| query: web::Query<PathQuery>, | |
| body: web::Json<WriteBody>, | |
| ) -> impl Responder { | |
| let rel = query.path.clone().unwrap_or_default(); | |
| let Some(abs) = safe_path(&rel) else { | |
| return HttpResponse::Forbidden().json(serde_json::json!({"error": "Access denied"})); | |
| }; | |
| if let Some(parent) = abs.parent() { | |
| let _ = fs::create_dir_all(parent); | |
| } | |
| match fs::write(&abs, body.content.as_bytes()) { | |
| Ok(_) => HttpResponse::Ok().json(serde_json::json!({"ok": true})), | |
| Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), | |
| } | |
| } | |
| async fn delete_file(query: web::Query<PathQuery>) -> impl Responder { | |
| let rel = query.path.clone().unwrap_or_default(); | |
| let Some(abs) = safe_path(&rel) else { | |
| return HttpResponse::Forbidden().json(serde_json::json!({"error": "Access denied"})); | |
| }; | |
| let result = if abs.is_dir() { | |
| fs::remove_dir_all(&abs) | |
| } else { | |
| fs::remove_file(&abs) | |
| }; | |
| match result { | |
| Ok(_) => HttpResponse::Ok().json(serde_json::json!({"ok": true})), | |
| Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), | |
| } | |
| } | |
| async fn make_dir(body: web::Json<CreateDirBody>) -> impl Responder { | |
| let path = format!("{}/{}", body.path.trim_end_matches('/'), body.name); | |
| let Some(abs) = safe_path(&path) else { | |
| return HttpResponse::Forbidden().json(serde_json::json!({"error": "Access denied"})); | |
| }; | |
| match fs::create_dir_all(&abs) { | |
| Ok(_) => HttpResponse::Ok().json(serde_json::json!({"ok": true})), | |
| Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), | |
| } | |
| } | |
| async fn upload_file(query: web::Query<PathQuery>, mut payload: Multipart) -> impl Responder { | |
| let dir = query.path.clone().unwrap_or_else(|| "/".to_string()); | |
| while let Some(field) = payload.next().await { | |
| let Ok(mut field) = field else { continue }; | |
| let filename = field | |
| .content_disposition() | |
| .get_filename() | |
| .unwrap_or("upload") | |
| .to_string(); | |
| let rel = format!("{}/{}", dir.trim_end_matches('/'), filename); | |
| let Some(abs) = safe_path(&rel) else { continue }; | |
| if let Some(parent) = abs.parent() { | |
| let _ = fs::create_dir_all(parent); | |
| } | |
| if let Ok(mut file) = fs::File::create(&abs) { | |
| while let Some(chunk) = field.next().await { | |
| if let Ok(data) = chunk { | |
| let _ = file.write_all(&data); | |
| } | |
| } | |
| } | |
| } | |
| HttpResponse::Ok().json(serde_json::json!({"ok": true})) | |
| } | |
| async fn search(query: web::Query<SearchQuery>) -> impl Responder { | |
| let root = PathBuf::from(ROOT_DIR); | |
| let base = query | |
| .path | |
| .as_deref() | |
| .and_then(|p| safe_path(p)) | |
| .unwrap_or_else(|| root.clone()); | |
| let mut results = Vec::new(); | |
| search_recursive(&base, &query.q, &mut results, &root); | |
| results.truncate(100); | |
| HttpResponse::Ok().json(results) | |
| } | |
| async fn system_stats() -> impl Responder { | |
| let root = PathBuf::from(ROOT_DIR); | |
| let _ = fs::create_dir_all(&root); | |
| let (files, dirs, size) = count_recursive(&root); | |
| // Read RAM from /proc/meminfo | |
| let (ram_used, ram_total) = fs::read_to_string("/proc/meminfo") | |
| .map(|s| { | |
| let mut total = 0u64; | |
| let mut available = 0u64; | |
| for line in s.lines() { | |
| if line.starts_with("MemTotal:") { | |
| total = line.split_whitespace().nth(1).and_then(|v| v.parse().ok()).unwrap_or(0); | |
| } else if line.starts_with("MemAvailable:") { | |
| available = line.split_whitespace().nth(1).and_then(|v| v.parse().ok()).unwrap_or(0); | |
| } | |
| } | |
| (total.saturating_sub(available) / 1024, total / 1024) | |
| }) | |
| .unwrap_or((0, 0)); | |
| HttpResponse::Ok().json(SystemStats { | |
| total_files: files, | |
| total_dirs: dirs, | |
| total_size: size, | |
| ram_used_mb: ram_used, | |
| ram_total_mb: ram_total, | |
| root_path: ROOT_DIR.to_string(), | |
| }) | |
| } | |
| async fn main() -> io::Result<()> { | |
| fs::create_dir_all(ROOT_DIR)?; | |
| println!("🦀 FileBrowser API running on :8080 | root={ROOT_DIR}"); | |
| HttpServer::new(|| { | |
| let cors = Cors::default() | |
| .allow_any_origin() | |
| .allow_any_method() | |
| .allow_any_header(); | |
| App::new() | |
| .wrap(cors) | |
| .service(list_dir) | |
| .service(read_file) | |
| .service(write_file) | |
| .service(delete_file) | |
| .service(make_dir) | |
| .service(upload_file) | |
| .service(search) | |
| .service(system_stats) | |
| }) | |
| .bind("0.0.0.0:8080")? | |
| .run() | |
| .await | |
| } | |
Xet Storage Details
- Size:
- 12.6 kB
- Xet hash:
- 9621423b9762dea8a6781790342472007707a0ed4aba9ffdbe5d8463940affeb
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.