From 2eee67bdd17cf307ce21fe2a31a0ce9a83c31b6a Mon Sep 17 00:00:00 2001 From: Nico Fricke Date: Thu, 16 Jan 2025 16:45:27 +0100 Subject: [PATCH] implement endpoint to list files in a directory --- casket-backend/src/errors.rs | 11 +++ casket-backend/src/extractor_helper.rs | 24 ++++++ casket-backend/src/files/list.rs | 107 +++++++++++++++++++++++++ casket-backend/src/files/mod.rs | 2 +- casket-backend/src/files/upload.rs | 11 +-- casket-backend/src/main.rs | 3 +- casket-backend/src/routes.rs | 8 +- 7 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 casket-backend/src/extractor_helper.rs create mode 100644 casket-backend/src/files/list.rs diff --git a/casket-backend/src/errors.rs b/casket-backend/src/errors.rs index 226bb62..05c4143 100644 --- a/casket-backend/src/errors.rs +++ b/casket-backend/src/errors.rs @@ -1,6 +1,17 @@ +use axum::http::StatusCode; +use problem_details::ProblemDetails; +use std::fmt::Debug; +use tracing::error; + pub const ERROR_DETAILS_PATH_TRAVERSAL: &str = "You must not traverse!"; pub const ERROR_DETAILS_INTERNAL_ERROR: &str = "Internal Error! Check the logs of the backend service for details."; pub const ERROR_DETAILS_ABSOLUTE_PATH_NOT_ALLOWED: &str = "Absolute paths are not allowed."; pub const ERROR_DETAILS_NO_NAME_PROVIDED_MULTIPART_FIELD: &str = "No name provided for multipart form field"; + +pub fn to_internal_error(error: impl Debug) -> ProblemDetails { + error!("{:?}", error); + ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) + .with_detail(ERROR_DETAILS_INTERNAL_ERROR) +} diff --git a/casket-backend/src/extractor_helper.rs b/casket-backend/src/extractor_helper.rs new file mode 100644 index 0000000..7f3a4a5 --- /dev/null +++ b/casket-backend/src/extractor_helper.rs @@ -0,0 +1,24 @@ +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::StatusCode; +use problem_details::ProblemDetails; +use std::fmt::Debug; + +pub struct WithProblemDetails(pub T); + +impl FromRequestParts for WithProblemDetails +where + T: FromRequestParts, + T::Rejection: Debug, + S: Sync, +{ + type Rejection = ProblemDetails; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match T::from_request_parts(parts, state).await { + Ok(inner) => Ok(Self(inner)), + Err(rejection) => Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("{rejection:?}"))), + } + } +} diff --git a/casket-backend/src/files/list.rs b/casket-backend/src/files/list.rs new file mode 100644 index 0000000..5833f2f --- /dev/null +++ b/casket-backend/src/files/list.rs @@ -0,0 +1,107 @@ +use crate::errors::to_internal_error; +use crate::extractor_helper::WithProblemDetails; +use crate::files::build_system_path; +use crate::{errors, AppState}; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::{debug_handler, Json}; +use problem_details::ProblemDetails; +use serde::{Deserialize, Serialize}; +use std::io::Error; +use std::path::PathBuf; +use tokio::fs::{read_dir, DirEntry}; +use tracing::{debug, error}; + +#[debug_handler] +pub async fn query_file_tree( + State(state): State, + axum::extract::Path(user_id): axum::extract::Path, + WithProblemDetails(Query(query)): WithProblemDetails>, +) -> Result, ProblemDetails> { + let path = build_system_path(&state.config.files.directory, &user_id, &query.path)?; + debug!("Loading path: {:?}", path); + Ok(PathElements::new(load_directory(path, query.nesting).await?).into()) +} + +async fn load_directory(path: PathBuf, nesting: u32) -> Result, ProblemDetails> { + match read_dir(path).await { + Err(error) => Err(map_io_error(error)), + Ok(mut value) => { + let mut result = vec![]; + while let Some(next) = value.next_entry().await.map_err(map_io_error)? { + result.push(PathElement::from(next, nesting).await?); + } + Ok(result) + } + } +} + +fn map_io_error(error: Error) -> ProblemDetails { + match error.kind() { + std::io::ErrorKind::NotFound => ProblemDetails::from_status_code(StatusCode::NOT_FOUND), + _ => to_internal_error(error), + } +} + +#[derive(Deserialize)] +pub struct FileQuery { + path: PathBuf, + #[serde(default = "u32::min_value")] + nesting: u32, +} + +#[derive(Serialize)] +pub struct PathElements { + files: Vec, +} + +impl PathElements { + pub fn new(files: Vec) -> Self { + Self { files } + } +} + +#[derive(Serialize)] +pub struct PathElement { + name: String, + is_dir: bool, + children: Option>, +} + +impl PathElement { + async fn from(value: DirEntry, nesting: u32) -> Result { + let metadata = value.metadata().await.unwrap(); + debug!("File type: {:?}", value.file_type().await.unwrap()); + let is_dir = metadata.is_dir(); + if is_dir || metadata.is_file() { + Ok(PathElement { + name: value.file_name().into_string().unwrap(), + is_dir, + children: load_children(value.path(), nesting, is_dir).await?, + }) + } else { + error!( + "File <{:?}> is neither a directory nor a file. ", + value.file_name() + ); + Err( + ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) + .with_detail(errors::ERROR_DETAILS_INTERNAL_ERROR), + ) + } + } +} + +async fn load_children( + path: PathBuf, + nesting: u32, + is_dir: bool, +) -> Result>, ProblemDetails> { + if nesting == 0 { + Ok(None) + } else if !is_dir { + Ok(Some(vec![])) + } else { + Ok(Some(Box::pin(load_directory(path, nesting - 1)).await?)) + } +} diff --git a/casket-backend/src/files/mod.rs b/casket-backend/src/files/mod.rs index 92f6413..d7f5d84 100644 --- a/casket-backend/src/files/mod.rs +++ b/casket-backend/src/files/mod.rs @@ -15,7 +15,7 @@ fn build_system_path( Err( ProblemDetails::from_status_code(StatusCode::BAD_REQUEST).with_detail( errors::ERROR_DETAILS_ABSOLUTE_PATH_NOT_ALLOWED.to_owned() - + format!(" Provided Path: {:?}", user_path).as_str(), + + format!(" Provided Path: {user_path:?}",).as_str(), ), ) } else { diff --git a/casket-backend/src/files/upload.rs b/casket-backend/src/files/upload.rs index fe20a40..c524792 100644 --- a/casket-backend/src/files/upload.rs +++ b/casket-backend/src/files/upload.rs @@ -1,3 +1,4 @@ +use crate::errors::to_internal_error; use crate::files::build_system_path; use crate::{errors, AppState}; use axum::body::Bytes; @@ -9,13 +10,11 @@ use futures::Stream; use futures_util::stream::MapErr; use futures_util::{StreamExt, TryStreamExt}; use problem_details::ProblemDetails; -use std::fmt::Debug; use std::io; use std::io::Error; -use std::path::{Component, Path, PathBuf}; +use std::path::PathBuf; use tokio::fs::{create_dir_all, File}; use tokio::io::AsyncWriteExt; -use tracing::error; #[debug_handler] pub async fn handle_file_uploads( @@ -66,12 +65,6 @@ pub async fn save_file( Ok(()) } -fn to_internal_error(error: impl Debug) -> ProblemDetails { - error!("{:?}", error); - ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) - .with_detail(errors::ERROR_DETAILS_INTERNAL_ERROR) -} - fn map_error_to_io_error(field: Field) -> MapErr Error> { field.map_err(|err| Error::new(io::ErrorKind::Other, err)) } diff --git a/casket-backend/src/main.rs b/casket-backend/src/main.rs index eac4644..e626b89 100644 --- a/casket-backend/src/main.rs +++ b/casket-backend/src/main.rs @@ -4,6 +4,7 @@ mod config; mod errors; mod files; mod routes; +mod extractor_helper; use axum::Router; use std::env; @@ -11,7 +12,7 @@ use std::process::exit; use std::str::FromStr; use tracing::{debug, error, info}; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct AppState { config: config::Config, } diff --git a/casket-backend/src/routes.rs b/casket-backend/src/routes.rs index cbb9d71..d2a8a89 100644 --- a/casket-backend/src/routes.rs +++ b/casket-backend/src/routes.rs @@ -2,11 +2,13 @@ use crate::files; use crate::AppState; use axum::routing::post; use axum::{response::Html, routing::get, Router}; +use files::list::query_file_tree; pub fn routes() -> Router { - Router::new() - .route("/", get(handler)) - .route("/api/v1/user/{:user_id}/files", post(files::upload::handle_file_uploads)) + Router::new().route("/", get(handler)).route( + "/api/v1/user/{:user_id}/files", + post(files::upload::handle_file_uploads).get(query_file_tree), + ) } async fn handler() -> Html<&'static str> {