Compare commits
2 Commits
2eee67bdd1
...
fb025fa4cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb025fa4cc | ||
|
|
f8a13f095b |
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -143,7 +143,7 @@ dependencies = [
|
||||
"problem_details",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
@ -837,12 +837,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
name = "tokio-util"
|
||||
version = "0.7.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@ -8,7 +8,7 @@ repository = "https://git.nifni.eu/nif/casket"
|
||||
[dependencies]
|
||||
axum = { version = "0.8.1", features = ["multipart", "macros"] }
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = { version = "0.7.13", features = ["io"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
figment = { version = "0.10.19", features = ["toml", "env"] }
|
||||
|
||||
67
casket-backend/src/files/download.rs
Normal file
67
casket-backend/src/files/download.rs
Normal file
@ -0,0 +1,67 @@
|
||||
use crate::errors::to_internal_error;
|
||||
use crate::extractor_helper::WithProblemDetails;
|
||||
use crate::files::build_system_path;
|
||||
use crate::AppState;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::Deserialize;
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::error;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(user_id): axum::extract::Path<String>,
|
||||
WithProblemDetails(Query(query)): WithProblemDetails<Query<SingleFileQuery>>,
|
||||
) -> Result<impl IntoResponse, ProblemDetails> {
|
||||
let filesystem_path = build_system_path(&state.config.files.directory, &user_id, &query.path)?;
|
||||
|
||||
let file_name = get_file_name(&filesystem_path)?;
|
||||
let file = open_file(&filesystem_path).await?;
|
||||
|
||||
let metadata = file.metadata().await.map_err(to_internal_error)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("Path {:?} is not a file", query.path)));
|
||||
}
|
||||
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
Ok((
|
||||
[(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{file_name}\""),
|
||||
)],
|
||||
body,
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_file(filesystem_path: &Path) -> Result<File, ProblemDetails> {
|
||||
File::open(filesystem_path).await.map_err(|err| {
|
||||
ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
|
||||
.with_detail(format!("Failed to open file: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn get_file_name(filesystem_path: &PathBuf) -> Result<Cow<str>, ProblemDetails> {
|
||||
filesystem_path
|
||||
.file_name()
|
||||
.ok_or_else(|| {
|
||||
error!("Failed to get file name for {:?}", filesystem_path);
|
||||
ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})
|
||||
.map(OsStr::to_string_lossy)
|
||||
}
|
||||
|
||||
/// Query to download a file
|
||||
#[derive(Deserialize)]
|
||||
pub struct SingleFileQuery {
|
||||
/// Path which is used as base path to query the tree.
|
||||
path: PathBuf,
|
||||
}
|
||||
@ -12,6 +12,7 @@ use std::path::PathBuf;
|
||||
use tokio::fs::{read_dir, DirEntry};
|
||||
use tracing::{debug, error};
|
||||
|
||||
/// Handler to query a file tree. The query parameters can be used to control what is is queried.
|
||||
#[debug_handler]
|
||||
pub async fn query_file_tree(
|
||||
State(state): State<AppState>,
|
||||
@ -20,56 +21,86 @@ pub async fn query_file_tree(
|
||||
) -> Result<Json<PathElements>, 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<Vec<PathElement>, 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),
|
||||
}
|
||||
PathElements::load(path, query.nesting)
|
||||
.await
|
||||
.map(Json::from)
|
||||
}
|
||||
|
||||
/// Query to control how a file tree is queried.
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileQuery {
|
||||
/// Path which is used as base path to query the tree.
|
||||
path: PathBuf,
|
||||
#[serde(default = "u32::min_value")]
|
||||
/// The amount of layers the tree should be loaded for. Can be used to cache a certain amount
|
||||
/// of levels for a better UX when browsing the data.
|
||||
nesting: u32,
|
||||
}
|
||||
|
||||
/// Represents the root of a file tree.
|
||||
#[derive(Serialize)]
|
||||
pub struct PathElements {
|
||||
files: Vec<PathElement>,
|
||||
}
|
||||
|
||||
/// Represents a node in the file tree.
|
||||
#[derive(Serialize)]
|
||||
pub struct PathElement {
|
||||
/// The name of the directory or file.
|
||||
name: String,
|
||||
is_dir: bool,
|
||||
/// Empty children means that the node is either a file or an empty directory.
|
||||
/// None means that the children have not been loaded.
|
||||
children: Option<Vec<PathElement>>,
|
||||
}
|
||||
|
||||
impl PathElements {
|
||||
pub fn new(files: Vec<PathElement>) -> Self {
|
||||
Self { files }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathElement {
|
||||
name: String,
|
||||
is_dir: bool,
|
||||
children: Option<Vec<PathElement>>,
|
||||
}
|
||||
/// Loads a path recursively nested for up to the provided nesting parameter.
|
||||
pub async fn load(path: PathBuf, nesting: u32) -> Result<Self, ProblemDetails> {
|
||||
Self::load_directory(path, nesting).await.map(Self::new)
|
||||
}
|
||||
|
||||
impl PathElement {
|
||||
async fn from(value: DirEntry, nesting: u32) -> Result<Self, ProblemDetails> {
|
||||
async fn load_directory(
|
||||
path: PathBuf,
|
||||
nesting: u32,
|
||||
) -> Result<Vec<PathElement>, ProblemDetails> {
|
||||
match read_dir(path).await {
|
||||
Err(error) => Err(Self::map_io_error(error)),
|
||||
Ok(mut value) => {
|
||||
let mut result = vec![];
|
||||
while let Some(next) = value.next_entry().await.map_err(Self::map_io_error)? {
|
||||
result.push(Self::load_path_element(next, nesting).await?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_children(
|
||||
path: PathBuf,
|
||||
nesting: u32,
|
||||
is_dir: bool,
|
||||
) -> Result<Option<Vec<PathElement>>, ProblemDetails> {
|
||||
if nesting == 0 {
|
||||
Ok(None)
|
||||
} else if !is_dir {
|
||||
Ok(Some(vec![]))
|
||||
} else {
|
||||
Ok(Some(
|
||||
// needs to call pin to support recursion in async contexts.
|
||||
Box::pin(Self::load_directory(path, nesting - 1)).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_path_element(
|
||||
value: DirEntry,
|
||||
nesting: u32,
|
||||
) -> Result<PathElement, ProblemDetails> {
|
||||
let metadata = value.metadata().await.unwrap();
|
||||
debug!("File type: {:?}", value.file_type().await.unwrap());
|
||||
let is_dir = metadata.is_dir();
|
||||
@ -77,11 +108,11 @@ impl PathElement {
|
||||
Ok(PathElement {
|
||||
name: value.file_name().into_string().unwrap(),
|
||||
is_dir,
|
||||
children: load_children(value.path(), nesting, is_dir).await?,
|
||||
children: Self::load_children(value.path(), nesting, is_dir).await?,
|
||||
})
|
||||
} else {
|
||||
error!(
|
||||
"File <{:?}> is neither a directory nor a file. ",
|
||||
"File <{:?}> is neither a directory nor a file.",
|
||||
value.file_name()
|
||||
);
|
||||
Err(
|
||||
@ -90,18 +121,11 @@ impl PathElement {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_children(
|
||||
path: PathBuf,
|
||||
nesting: u32,
|
||||
is_dir: bool,
|
||||
) -> Result<Option<Vec<PathElement>>, ProblemDetails> {
|
||||
if nesting == 0 {
|
||||
Ok(None)
|
||||
} else if !is_dir {
|
||||
Ok(Some(vec![]))
|
||||
} else {
|
||||
Ok(Some(Box::pin(load_directory(path, nesting - 1)).await?))
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,11 @@ use axum::http::StatusCode;
|
||||
use problem_details::ProblemDetails;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub mod download;
|
||||
pub mod list;
|
||||
pub mod upload;
|
||||
|
||||
fn build_system_path(
|
||||
pub fn build_system_path(
|
||||
base_directory: &Path,
|
||||
user_id: &str,
|
||||
user_path: &PathBuf,
|
||||
@ -23,7 +24,7 @@ fn build_system_path(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sanitize_path(path: &Path) -> Result<PathBuf, ProblemDetails> {
|
||||
fn sanitize_path(path: &Path) -> Result<PathBuf, ProblemDetails> {
|
||||
let mut ret = PathBuf::new();
|
||||
for component in path.components().peekable() {
|
||||
match component {
|
||||
|
||||
@ -53,7 +53,7 @@ async fn handle_single_file_upload<'field_lifetime>(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_file(
|
||||
async fn save_file(
|
||||
path: PathBuf,
|
||||
mut content: impl Stream<Item = Result<Bytes, Error>> + Unpin,
|
||||
) -> Result<(), Error> {
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
use crate::files;
|
||||
use crate::files::download;
|
||||
use crate::AppState;
|
||||
use axum::routing::post;
|
||||
use axum::{response::Html, routing::get, Router};
|
||||
use files::list::query_file_tree;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/", get(handler)).route(
|
||||
"/api/v1/user/{:user_id}/files",
|
||||
post(files::upload::handle_file_uploads).get(query_file_tree),
|
||||
)
|
||||
Router::new()
|
||||
.route("/", get(handler))
|
||||
.route(
|
||||
"/api/v1/user/{:user_id}/files",
|
||||
post(files::upload::handle_file_uploads).get(query_file_tree),
|
||||
)
|
||||
.route("/api/v1/user/{:user_id}/download", get(download::handler))
|
||||
}
|
||||
|
||||
async fn handler() -> Html<&'static str> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user