use anyhow::Result;
use chrono::{DateTime};
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{debug, info};
use walkdir::WalkDir;
use crate::{
config::Config,
models::file::{File, Directory},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DirectoryEntry {
File(crate::models::file::File),
Directory(crate::models::file::Directory),
}
pub struct FileService {
root_path: PathBuf,
max_file_size: u64,
}
impl FileService {
pub async fn new(config: std::sync::Arc<Config>) -> Result<Self> {
let root_path = config.server.root_dir.clone();
if !root_path.exists() {
info!("Creating root directory: {}", root_path.display());
fs::create_dir_all(&root_path).await?;
}
Ok(Self {
root_path,
max_file_size: config.server.max_upload_size as u64,
})
}
pub async fn list_directory(&self, path: &str) -> Result<Directory> {
let full_path = self.resolve_path(path)?;
debug!("Listing directory: {}", full_path.display());
if !full_path.is_dir() {
return Err(anyhow::anyhow!("Path is not a directory"));
}
let metadata = full_path.metadata()?;
let modified = DateTime::from(metadata.modified()?);
let mut entries = Vec::new();
let mut read_dir = fs::read_dir(&full_path).await?;
while let Some(entry) = read_dir.next_entry().await? {
let entry_path = entry.path();
let entry_metadata = entry.metadata().await?;
let name = entry.file_name().to_string_lossy().into_owned();
if entry_metadata.is_file() {
let file = File {
name: name.clone(),
path: entry_path.to_string_lossy().to_string(),
size: entry_metadata.len(),
modified: entry_metadata.modified()?.into(),
mime_type: mime_guess::from_path(&entry_path).first_or_octet_stream().to_string(),
is_text: crate::utils::is_text_file(&entry_path),
};
entries.push(DirectoryEntry::File(file));
} else if entry_metadata.is_dir() {
let dir = Directory {
name,
path: entry_path.to_string_lossy().to_string(),
modified: entry_metadata.modified()?.into(),
entries: Vec::new(), };
entries.push(DirectoryEntry::Directory(dir));
}
}
entries.sort_by(|a, b| {
match (a, b) {
(DirectoryEntry::Directory(a), DirectoryEntry::Directory(b)) => a.name.cmp(&b.name),
(DirectoryEntry::File(a), DirectoryEntry::File(b)) => a.name.cmp(&b.name),
(DirectoryEntry::Directory(_), DirectoryEntry::File(_)) => std::cmp::Ordering::Less,
(DirectoryEntry::File(_), DirectoryEntry::Directory(_)) => std::cmp::Ordering::Greater,
}
});
Ok(Directory {
name: full_path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: path.to_string(),
modified,
entries,
})
}
pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
let full_path = self.resolve_path(path)?;
debug!("Reading file: {}", full_path.display());
if !full_path.is_file() {
return Err(anyhow::anyhow!("Path is not a file"));
}
let mut file = fs::File::open(&full_path).await?;
let mut contents = Vec::new();
file.read_to_end(&mut contents).await?;
Ok(contents)
}
pub async fn write_file(&self, path: &str, contents: &[u8]) -> Result<()> {
if contents.len() as u64 > self.max_file_size {
return Err(anyhow::anyhow!("File size exceeds maximum allowed size"));
}
let full_path = self.resolve_path(path)?;
debug!("Writing file: {} ({} bytes)", full_path.display(), contents.len());
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
let mut file = fs::File::create(&full_path).await?;
file.write_all(contents).await?;
file.flush().await?;
info!("File written successfully: {}", full_path.display());
Ok(())
}
pub async fn delete(&self, path: &str) -> Result<()> {
let full_path = self.resolve_path(path)?;
debug!("Deleting path: {}", full_path.display());
if full_path.is_file() {
fs::remove_file(&full_path).await?;
} else if full_path.is_dir() {
fs::remove_dir_all(&full_path).await?;
} else {
return Err(anyhow::anyhow!("Path does not exist"));
}
info!("Path deleted successfully: {}", full_path.display());
Ok(())
}
pub async fn get_metadata(&self, path: &str) -> Result<File> {
let full_path = self.resolve_path(path)?;
debug!("Getting metadata for: {}", full_path.display());
if !full_path.is_file() {
return Err(anyhow::anyhow!("Path is not a file"));
}
let metadata = full_path.metadata()?;
let name = full_path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Ok(File {
name,
path: path.to_string(),
size: metadata.len(),
modified: metadata.modified()?.into(),
mime_type: mime_guess::from_path(&full_path).first_or_octet_stream().to_string(),
is_text: crate::utils::is_text_file(&full_path),
})
}
pub async fn search_files(&self, query: &str) -> Result<Vec<File>> {
debug!("Searching files with query: {}", query);
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for entry in WalkDir::new(&self.root_path)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
let path = entry.path();
let filename = path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if filename.contains(&query_lower) {
if let Ok(file) = self.path_to_file(path).await {
results.push(file);
}
}
}
}
Ok(results)
}
pub async fn create_directory(&self, path: &str) -> Result<()> {
let full_path = self.resolve_path(path)?;
debug!("Creating directory: {}", full_path.display());
fs::create_dir_all(&full_path).await?;
info!("Directory created: {}", full_path.display());
Ok(())
}
pub async fn copy(&self, from: &str, to: &str) -> Result<()> {
let from_path = self.resolve_path(from)?;
let to_path = self.resolve_path(to)?;
debug!("Copying from {} to {}", from_path.display(), to_path.display());
if from_path.is_file() {
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::copy(&from_path, &to_path).await?;
} else if from_path.is_dir() {
self.copy_dir_recursive(&from_path, &to_path).await?;
}
info!("Copy completed from {} to {}", from_path.display(), to_path.display());
Ok(())
}
pub async fn move_item(&self, from: &str, to: &str) -> Result<()> {
let from_path = self.resolve_path(from)?;
let to_path = self.resolve_path(to)?;
debug!("Moving from {} to {}", from_path.display(), to_path.display());
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::rename(&from_path, &to_path).await?;
info!("Move completed from {} to {}", from_path.display(), to_path.display());
Ok(())
}
pub async fn get_disk_usage(&self) -> Result<DiskUsage> {
debug!("Getting disk usage for: {}", self.root_path.display());
let mut total_size = 0;
let mut file_count = 0;
let mut dir_count = 0;
for entry in WalkDir::new(&self.root_path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Ok(metadata) = entry.metadata() {
total_size += metadata.len();
file_count += 1;
}
} else if entry.file_type().is_dir() {
dir_count += 1;
}
}
Ok(DiskUsage {
total_size,
file_count,
directory_count: dir_count,
formatted_size: crate::utils::format_file_size(total_size),
})
}
fn resolve_path(&self, path: &str) -> Result<PathBuf> {
let clean_path = path.trim_start_matches('/');
let full_path = self.root_path.join(clean_path);
let mut normalized = PathBuf::new();
for component in full_path.components() {
match component {
std::path::Component::Normal(name) => normalized.push(name),
std::path::Component::ParentDir => {
if !normalized.pop() {
return Err(anyhow::anyhow!("Path outside of allowed directory"));
}
}
std::path::Component::CurDir => {
}
std::path::Component::RootDir => normalized.push("/"),
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
}
}
if !normalized.starts_with(&self.root_path) {
return Err(anyhow::anyhow!("Path outside of allowed directory"));
}
Ok(normalized)
}
fn relative_path(&self, full_path: &Path) -> Result<String> {
let relative = full_path.strip_prefix(&self.root_path)?;
Ok(relative.to_string_lossy().to_string())
}
async fn path_to_file(&self, path: &Path) -> Result<File> {
let metadata = path.metadata()?;
let name = path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let relative_path = self.relative_path(path)?;
Ok(File {
name,
path: relative_path,
size: metadata.len(),
modified: metadata.modified()?.into(),
mime_type: mime_guess::from_path(&path).first_or_octet_stream().to_string(),
is_text: crate::utils::is_text_file(&path),
})
}
fn copy_dir_recursive<'a>(&'a self, _from: &'a Path, _to: &'a Path) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
Ok(())
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiskUsage {
pub total_size: u64,
pub file_count: u64,
pub directory_count: u64,
pub formatted_size: String,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn create_test_service() -> (FileService, TempDir) {
let temp_dir = TempDir::new().unwrap();
let mut config = crate::config::Config::default();
config.server.root_dir = temp_dir.path().to_path_buf();
config.server.max_upload_size = 1024 * 1024;
let service = FileService::new(std::sync::Arc::new(config)).await.unwrap();
(service, temp_dir)
}
#[tokio::test]
async fn test_write_and_read_file() {
let (service, _temp_dir) = create_test_service().await;
let content = b"Hello, world!";
service.write_file("test.txt", content).await.unwrap();
let read_content = service.read_file("test.txt").await.unwrap();
assert_eq!(content, read_content.as_slice());
}
#[tokio::test]
async fn test_create_and_list_directory() {
let (service, _temp_dir) = create_test_service().await;
service.create_directory("test_dir").await.unwrap();
service.write_file("test_dir/file.txt", b"test").await.unwrap();
let dir = service.list_directory("test_dir").await.unwrap();
assert_eq!(dir.entries.len(), 1);
}
#[tokio::test]
async fn test_path_traversal_prevention() {
let (service, _temp_dir) = create_test_service().await;
let result = service.read_file("../../../etc/passwd").await;
assert!(result.is_err());
}
}