use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::extract::{Multipart, Query, State};
use axum::http::header;
use axum::response::{Json, Response};
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use super::error::{Error, Result};
use super::path::VersionedPath;
use super::request::{FileDownloadQuery, FileListQuery, FileSaveRequest};
use super::response::{FileEntryType, FileInfo, FileListResponse, FileUploadResponse, FileVersion};
use super::state::FileState;
use super::utils;
use crate::settings::{Dirs, IGNORE_FILES, UPLOAD_DIR, VERSION_DIR};
#[utoipa::path(
post,
path = "/upload",
request_body(content = String, content_type = "multipart/form-data"),
responses(
(status = 200, description = "File uploaded successfully", body = FileUploadResponse),
(status = 400, description = "Bad request", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn upload(
State(state): State<FileState>,
mut multipart: Multipart,
) -> Result<Json<FileUploadResponse>> {
let mut file_name: Option<String> = None;
let mut file_data: Option<Vec<u8>> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_e| Error::BadRequest("Invalid multipart data".to_string()))?
{
match field.name().unwrap_or("") {
"file" => {
file_name = field.file_name().map(ToString::to_string);
file_data = Some(
field
.bytes()
.await
.map_err(|e| Error::BadRequest(format!("Failed to read file data: {e:?}")))?
.to_vec(),
);
}
"name" => {
file_name =
Some(field.text().await.map_err(|e| {
Error::BadRequest(format!("Failed to read name field: {e:?}"))
})?);
}
_ => {}
}
}
let Some(data) = file_data else {
return Err(Error::BadRequest("No file provided".to_string()));
};
let sanitized_name =
utils::sanitize_filename(&file_name.unwrap_or_else(|| "unnamed".to_string()));
save(&sanitized_name, &data, state.base.as_ref()).await
}
#[utoipa::path(
post,
path = "/save",
request_body = FileSaveRequest,
responses(
(status = 200, description = "File saved successfully", body = FileUploadResponse),
(status = 400, description = "Bad request", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn save_file(
State(state): State<FileState>,
Json(request): Json<FileSaveRequest>,
) -> Result<Json<FileUploadResponse>> {
let name = request.name.unwrap_or_else(|| "unnamed.txt".to_string());
let sanitized_name = utils::sanitize_filename(&name);
save(&sanitized_name, request.content.as_bytes(), state.base.as_ref()).await
}
#[utoipa::path(
get,
path = "/list",
params(FileListQuery),
responses(
(status = 200, description = "Files and directories listed successfully", body = FileListResponse),
(status = 400, description = "Bad request", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn list_files(
State(state): State<FileState>,
Query(params): Query<FileListQuery>,
) -> Result<Json<FileListResponse>> {
let base_dir = state.base.as_ref().unwrap_or(Dirs::get()).data.clone().join(UPLOAD_DIR);
let target_dir = if let Some(path) = params.path {
let sanitized = utils::sanitize_path(&path);
base_dir.join(sanitized)
} else {
base_dir
};
if !target_dir.exists() {
return Ok(Json(FileListResponse { files: vec![] }));
}
Ok(Json(FileListResponse { files: collect_files(target_dir).await? }))
}
async fn collect_files(target_dir: impl AsRef<Path>) -> Result<Vec<FileInfo>> {
let mut files = Vec::new();
let mut entries_vec = Vec::new();
let mut entries = fs::read_dir(target_dir.as_ref())
.await
.map_err(utils::map_file_err("Failed to read directory"))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(utils::map_file_err("Failed to read directory entry"))?
{
entries_vec.push(entry);
}
for (name, entry) in entries_vec
.into_iter()
.map(|e| (e.file_name().to_string_lossy().to_string(), e))
.filter(|(name, _)| !IGNORE_FILES.iter().any(|i| name.starts_with(i)))
{
let metadata =
entry.metadata().await.map_err(utils::map_file_err("Failed to read file metadata"))?;
let path = entry.path();
if metadata.is_file() {
let size = metadata.len();
let created = utils::get_created_time(&metadata);
let modified = utils::get_modified_time(&metadata);
files.push(FileInfo {
name,
size,
entry_type: FileEntryType::File,
created,
modified,
versions: None,
});
} else if metadata.is_dir() {
let versions_path = path.join(VERSION_DIR);
if fs::try_exists(&versions_path).await.unwrap_or(false) {
let mut versions = Vec::new();
let mut versions_dir = fs::read_dir(&versions_path)
.await
.map_err(utils::map_file_err("Failed to read version dir"))?;
while let Some(version_entry) = versions_dir
.next_entry()
.await
.map_err(utils::map_file_err("Failed to get entry for version"))?
{
let version_meta = version_entry
.metadata()
.await
.map_err(utils::map_file_err("Failed to get metadata for version"))?;
if version_meta.is_file() {
versions.push(FileVersion {
uuid: version_entry.file_name().to_string_lossy().to_string(),
size: version_meta.len(),
created: utils::get_created_time(&version_meta),
});
}
}
versions.sort_by(|a, b| b.uuid.cmp(&a.uuid));
let mut version = FileInfo {
name,
size: 0,
entry_type: FileEntryType::VersionedFile,
created: None,
modified: None,
versions: None,
};
if !versions.is_empty() {
version.size = versions.first().map_or(0, |v| v.size);
version.modified = versions.first().and_then(|v| v.created);
version.created = versions.last().and_then(|v| v.created);
version.versions = Some(versions);
}
files.push(version);
} else {
files.push(FileInfo {
name,
size: 0,
entry_type: FileEntryType::Directory,
created: None,
modified: None,
versions: None,
});
}
}
}
files.sort_by(|a, b| b.name.cmp(&a.name));
Ok(files)
}
async fn save(name: &str, data: &[u8], base: Option<&Dirs>) -> Result<Json<FileUploadResponse>> {
let (uuid, file_dir, file_path) = utils::create_versioned_filepath(name, base);
fs::create_dir_all(&file_dir)
.await
.map_err(utils::map_file_err("Failed to create upload directory"))?;
let mut file =
File::create(&file_path).await.map_err(utils::map_file_err("Failed to create file"))?;
file.write_all(data).await.map_err(utils::map_file_err("Failed to write file"))?;
file.flush().await.map_err(utils::map_file_err("Failed to flush file"))?;
Ok(Json(FileUploadResponse {
uuid: uuid.to_string(),
success: true,
path: name.to_string(),
full_path: file_path.to_string_lossy().to_string(),
}))
}
#[utoipa::path(
get,
path = "/file/cache/{path}",
params(
("path" = String, Path, description = "Path to file relative to cache directory")
),
responses(
(status = 200, description = "File content", content_type = "application/octet-stream"),
(status = 404, description = "File not found", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn download_cache(
State(state): State<FileState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> Result<Response<Body>> {
let base = state.base.as_ref().unwrap_or(Dirs::get());
let sanitized = utils::sanitize_path(&path);
let file_path = base.cache.join(sanitized);
stream_file(file_path).await
}
#[utoipa::path(
get,
path = "/file/data/{path}",
params(
("path" = String, Path, description = "Path to file relative to data directory")
),
responses(
(status = 200, description = "File content", content_type = "application/octet-stream"),
(status = 404, description = "File not found", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn download_data(
State(state): State<FileState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> Result<Response<Body>> {
let base = state.base.as_ref().unwrap_or(Dirs::get());
let sanitized = utils::sanitize_path(&path);
let file_path = base.data.join(sanitized);
stream_file(file_path).await
}
#[utoipa::path(
get,
path = "/file/upload/{path}",
params(
("path" = String, Path, description = "Path to versioned file relative to uploads directory"),
FileDownloadQuery
),
responses(
(status = 200, description = "File content", content_type = "application/octet-stream"),
(status = 404, description = "File not found", body = stately::ApiError),
(status = 500, description = "Internal server error", body = stately::ApiError)
),
tag = "files"
)]
pub async fn download_upload(
State(state): State<FileState>,
axum::extract::Path(path): axum::extract::Path<String>,
Query(params): Query<FileDownloadQuery>,
) -> Result<Response<Body>> {
let base = state.base.as_ref().unwrap_or(Dirs::get());
let sanitized = utils::sanitize_path(&path);
let uploads_dir = base.data.join(UPLOAD_DIR);
let file_path = if let Some(version) = params.version {
uploads_dir.join(&sanitized).join(VERSION_DIR).join(version)
} else {
let versioned = VersionedPath::new(sanitized.to_string_lossy().to_string());
versioned.resolve(&uploads_dir).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound(format!("File not found: {path}"))
} else {
Error::Internal(format!("Failed to resolve file: {e}"))
}
})?
};
stream_file(file_path).await
}
pub async fn stream_file(file_path: PathBuf) -> Result<Response<Body>> {
if !file_path.exists() {
return Err(Error::NotFound(format!("File not found: {}", file_path.display())));
}
let metadata = fs::metadata(&file_path)
.await
.map_err(utils::map_file_err("Failed to read file metadata"))?;
if !metadata.is_file() {
return Err(Error::BadRequest(format!("Path is not a file: {}", file_path.display())));
}
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream().to_string();
let file = File::open(&file_path).await.map_err(utils::map_file_err("Failed to open file"))?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("download");
let response = Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, metadata.len())
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""))
.body(body)
.map_err(|e| Error::Internal(format!("Failed to build response: {e}")))?;
Ok(response)
}