From fb025fa4cc0b8f311805f6308b04f0250252609b Mon Sep 17 00:00:00 2001 From: Nico Fricke Date: Fri, 17 Jan 2025 13:56:56 +0100 Subject: [PATCH] implement file downloads --- Cargo.lock | 10 +++-- casket-backend/Cargo.toml | 2 +- casket-backend/src/files/download.rs | 67 ++++++++++++++++++++++++++++ casket-backend/src/files/mod.rs | 5 ++- casket-backend/src/routes.rs | 12 +++-- 5 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 casket-backend/src/files/download.rs diff --git a/Cargo.lock b/Cargo.lock index 4a4ac9c..7a37cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/casket-backend/Cargo.toml b/casket-backend/Cargo.toml index a84789d..f6a9628 100644 --- a/casket-backend/Cargo.toml +++ b/casket-backend/Cargo.toml @@ -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"] } diff --git a/casket-backend/src/files/download.rs b/casket-backend/src/files/download.rs new file mode 100644 index 0000000..41d506d --- /dev/null +++ b/casket-backend/src/files/download.rs @@ -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, + axum::extract::Path(user_id): axum::extract::Path, + WithProblemDetails(Query(query)): WithProblemDetails>, +) -> Result { + 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::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, 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, +} diff --git a/casket-backend/src/files/mod.rs b/casket-backend/src/files/mod.rs index d7f5d84..5b88616 100644 --- a/casket-backend/src/files/mod.rs +++ b/casket-backend/src/files/mod.rs @@ -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 { +fn sanitize_path(path: &Path) -> Result { let mut ret = PathBuf::new(); for component in path.components().peekable() { match component { diff --git a/casket-backend/src/routes.rs b/casket-backend/src/routes.rs index d2a8a89..2ed73fa 100644 --- a/casket-backend/src/routes.rs +++ b/casket-backend/src/routes.rs @@ -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 { - 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> {