implement file downloads

This commit is contained in:
Nico Fricke 2025-01-17 13:56:56 +01:00
parent f8a13f095b
commit fb025fa4cc
5 changed files with 85 additions and 11 deletions

10
Cargo.lock generated
View File

@ -143,7 +143,7 @@ dependencies = [
"problem_details", "problem_details",
"serde", "serde",
"tokio", "tokio",
"tokio-stream", "tokio-util",
"tracing", "tracing",
"tracing-log", "tracing-log",
"tracing-subscriber", "tracing-subscriber",
@ -837,12 +837,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-util"
version = "0.1.17" version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
dependencies = [ dependencies = [
"bytes",
"futures-core", "futures-core",
"futures-sink",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]

View File

@ -8,7 +8,7 @@ repository = "https://git.nifni.eu/nif/casket"
[dependencies] [dependencies]
axum = { version = "0.8.1", features = ["multipart", "macros"] } axum = { version = "0.8.1", features = ["multipart", "macros"] }
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }
tokio-stream = "0.1.17" tokio-util = { version = "0.7.13", features = ["io"] }
futures = "0.3" futures = "0.3"
futures-util = "0.3" futures-util = "0.3"
figment = { version = "0.10.19", features = ["toml", "env"] } figment = { version = "0.10.19", features = ["toml", "env"] }

View 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,
}

View File

@ -3,10 +3,11 @@ use axum::http::StatusCode;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
pub mod download;
pub mod list; pub mod list;
pub mod upload; pub mod upload;
fn build_system_path( pub fn build_system_path(
base_directory: &Path, base_directory: &Path,
user_id: &str, user_id: &str,
user_path: &PathBuf, 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(); let mut ret = PathBuf::new();
for component in path.components().peekable() { for component in path.components().peekable() {
match component { match component {

View File

@ -1,14 +1,18 @@
use crate::files; use crate::files;
use crate::files::download;
use crate::AppState; use crate::AppState;
use axum::routing::post; use axum::routing::post;
use axum::{response::Html, routing::get, Router}; use axum::{response::Html, routing::get, Router};
use files::list::query_file_tree; use files::list::query_file_tree;
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new().route("/", get(handler)).route( Router::new()
"/api/v1/user/{:user_id}/files", .route("/", get(handler))
post(files::upload::handle_file_uploads).get(query_file_tree), .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> { async fn handler() -> Html<&'static str> {