EricaLuvGemma's picture
download
raw
12.6 kB
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
}
}
#[derive(Serialize)]
struct FileEntry {
name: String,
path: String,
is_dir: bool,
size: u64,
modified: u64,
mime: String,
}
#[derive(Serialize)]
struct DirListing {
path: String,
entries: Vec<FileEntry>,
}
#[derive(Serialize)]
struct SystemStats {
total_files: u64,
total_dirs: u64,
total_size: u64,
ram_used_mb: u64,
ram_total_mb: u64,
root_path: String,
}
#[derive(Deserialize)]
struct PathQuery {
path: Option<String>,
}
#[derive(Deserialize)]
struct SearchQuery {
q: String,
path: Option<String>,
}
#[derive(Deserialize)]
struct WriteBody {
content: String,
}
#[derive(Deserialize)]
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);
}
}
}
}
#[get("/api/list")]
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,
})
}
#[get("/api/file")]
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()),
}
}
#[put("/api/file")]
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()})),
}
}
#[delete("/api/file")]
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()})),
}
}
#[post("/api/mkdir")]
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()})),
}
}
#[post("/api/upload")]
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}))
}
#[get("/api/search")]
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)
}
#[get("/api/stats")]
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(),
})
}
#[actix_web::main]
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.