implement user creation, getting and deletion, add locking to file uploads, add some more .bru

This commit is contained in:
Nico Fricke 2025-02-02 17:34:58 +01:00
parent 163d2a61ca
commit 50d0f6dfa5
21 changed files with 264 additions and 120 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/target
.idea
casket.sqlite
casket.sqlite*
data

View File

@ -12,6 +12,7 @@ pub trait Repository {
&self,
id: &str,
) -> impl std::future::Future<Output = Result<UserModel, ProblemDetails>> + Send;
fn delete_user(&self, id: &str) -> impl std::future::Future<Output = ()> + Send;
fn create_file(
&self,
file: File,
@ -45,6 +46,6 @@ pub mod models {
pub mod insert {
pub struct File {
pub user_id: String,
pub path: String
pub path: String,
}
}

View File

@ -88,6 +88,15 @@ impl Repository for Sqlite {
self.get_user(id).await.map(|option| option.unwrap())
}
async fn delete_user(&self, id: &str) {
self.get_connection()
.execute("DELETE FROM files WHERE user_id = ?;", (id,))
.unwrap();
self.get_connection()
.execute("DELETE FROM users WHERE uuid = ?;", (id,))
.unwrap();
}
async fn create_file(&self, file: File) -> Result<FileModel, ProblemDetails> {
self.get_connection()
.execute(

View File

@ -1,6 +1,6 @@
use crate::errors::to_internal_error;
use crate::extractor_helper::WithProblemDetails;
use crate::files::build_system_path;
use crate::files::PathInfo;
use crate::AppState;
use axum::body::Body;
use axum::extract::{Query, State};
@ -20,7 +20,8 @@ pub async fn handler(
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 filesystem_path =
PathInfo::build(&state.config.files.directory, &user_id, &query.path)?.file_system_location;
let file_name = get_file_name(&filesystem_path)?;
let file = open_file(&filesystem_path).await?;

View File

@ -1,6 +1,6 @@
use crate::errors::to_internal_error;
use crate::extractor_helper::WithProblemDetails;
use crate::files::build_system_path;
use crate::files::PathInfo;
use crate::{errors, AppState};
use axum::extract::{Query, State};
use axum::http::StatusCode;
@ -19,7 +19,8 @@ pub async fn query_file_tree(
axum::extract::Path(user_id): axum::extract::Path<String>,
WithProblemDetails(Query(query)): WithProblemDetails<Query<FileQuery>>,
) -> Result<Json<PathElements>, ProblemDetails> {
let path = build_system_path(&state.config.files.directory, &user_id, &query.path)?;
let path =
PathInfo::build(&state.config.files.directory, &user_id, &query.path)?.file_system_location;
debug!("Loading path: {:?}", path);
PathElements::load(path, query.nesting)
.await

View File

@ -7,20 +7,33 @@ pub mod download;
pub mod list;
pub mod upload;
pub fn build_system_path(
base_directory: &Path,
user_id: &str,
user_path: &PathBuf,
) -> Result<PathBuf, ProblemDetails> {
if user_path.is_absolute() {
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(),
),
)
} else {
sanitize_path(&base_directory.join(user_id).join(user_path))
#[derive(Debug, PartialEq)]
pub struct PathInfo {
file_system_location: PathBuf,
relative_user_location: PathBuf,
}
impl PathInfo {
pub fn build(
base_directory: &Path,
user_id: &str,
user_path: &PathBuf,
) -> Result<Self, ProblemDetails> {
if user_path.is_absolute() {
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(),
),
)
} else {
let user_path = sanitize_path(user_path)?;
let system_path = sanitize_path(&base_directory.join(&user_id))?.join(&user_path);
Ok(Self {
file_system_location: system_path,
relative_user_location: user_path,
})
}
}
}
@ -96,21 +109,23 @@ mod tests {
}
#[test]
fn test_build_system_path_success() {
fn test_build_path_info() {
let input_path = PathBuf::from("tmp/blub");
let user_id = "bla";
let path_info = PathInfo::build(&PathBuf::from("/tmp/bla"), user_id, &input_path).unwrap();
assert_eq!(
build_system_path(&PathBuf::from("/tmp/bla"), user_id, &input_path).unwrap(),
path_info.file_system_location,
PathBuf::from("/tmp/bla/bla/tmp/blub")
);
assert_eq!(path_info.relative_user_location, PathBuf::from(input_path));
}
#[test]
fn test_build_system_path_error_with_absolute_user_path_returns_error() {
let input_path = PathBuf::from("tmp/blub");
fn test_build_path_info_error_with_absolute_user_path_returns_error() {
let input_path = PathBuf::from("/tmp/blub");
let user_id = "bla";
assert_eq!(
build_system_path(&PathBuf::from("/tmp/bla"), user_id, &input_path),
PathInfo::build(&PathBuf::from("/tmp/bla"), user_id, &input_path),
Err(
ProblemDetails::from_status_code(StatusCode::BAD_REQUEST).with_detail(
errors::ERROR_DETAILS_ABSOLUTE_PATH_NOT_ALLOWED.to_owned()

View File

@ -1,6 +1,7 @@
use crate::db::repository::{insert, Repository};
use crate::errors::to_internal_error;
use crate::files::build_system_path;
use crate::files::PathInfo;
use crate::locking::Lock;
use crate::{errors, AppState};
use axum::body::Bytes;
use axum::debug_handler;
@ -46,15 +47,38 @@ async fn handle_single_file_upload<'field_lifetime>(
.with_detail(errors::ERROR_DETAILS_NO_NAME_PROVIDED_MULTIPART_FIELD)),
Some(field_name) => {
let path = PathBuf::from(field_name);
let filesystem_path = build_system_path(&state.config.files.directory, user_id, &path)?;
save_file(&filesystem_path, map_error_to_io_error(field))
.await
.map_err(to_internal_error)?;
update_file_version(state, user_id, &filesystem_path).await
let path_info = PathInfo::build(&state.config.files.directory, user_id, &path)?;
let lock_id = &build_lock_id(user_id, &path_info.relative_user_location);
if state
.get_lock()
.lock(lock_id).await
{
let result = update_file(state, user_id, field, &path_info).await;
state.get_lock().unlock(lock_id).await;
result
} else {
Err(ProblemDetails::from_status_code(StatusCode::CONFLICT)
.with_detail(format!("Cannot upload file because another application is currently modifying that: {:?}", path_info.relative_user_location)))
}
}
}
}
async fn update_file<'field_lifetime>(
state: &AppState,
user_id: &str,
field: Field<'field_lifetime>,
path_info: &PathInfo,
) -> Result<(), ProblemDetails> {
save_file(
&path_info.file_system_location,
map_error_to_io_error(field),
)
.await
.map_err(to_internal_error)?;
update_file_version(state, user_id, &path_info.relative_user_location).await
}
async fn update_file_version(
state: &AppState,
user_id: &str,
@ -90,3 +114,8 @@ async fn save_file(
fn map_error_to_io_error(field: Field) -> MapErr<Field, fn(MultipartError) -> Error> {
field.map_err(|err| Error::new(io::ErrorKind::Other, err))
}
fn build_lock_id(user_id: &str, path: &Path) -> String {
let path_string = path.to_string_lossy();
format!("UPLOAD_{user_id}_{path_string}")
}

View File

@ -1,6 +1,3 @@
use crate::locking::LockingResult::{LOCKED, SUCCESS};
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
@ -37,54 +34,15 @@ impl Lock for SimpleLock {
}
pub trait Lock {
async fn lock(&self, id: &str) -> bool;
async fn unlock(&self, id: &str);
async fn run_if_not_locked<T, RESULT>(
&self,
lock_id: &str,
func: fn() -> RESULT,
) -> LockingResult<T>
where
RESULT: Future<Output = T>,
{
if self.lock(lock_id).await {
let result = func().await;
self.unlock(lock_id).await;
SUCCESS(result)
} else {
LOCKED
}
}
async fn run_if_locked<T, RESULT>(
&self,
lock_id: &str,
func: fn() -> RESULT,
) -> Result<T, ProblemDetails>
where
RESULT: Future<Output = T>,
{
match self.run_if_not_locked(lock_id, func).await {
LOCKED => Err(ProblemDetails::from_status_code(StatusCode::CONFLICT)
.with_detail(format!("Locked {:?}", lock_id))),
SUCCESS(result) => Ok(result),
}
}
}
#[derive(PartialEq, Debug)]
enum LockingResult<T> {
LOCKED,
SUCCESS(T),
fn lock(&self, id: &str) -> impl Future<Output = bool> + Send;
fn unlock(&self, id: &str) -> impl Future<Output = ()> + Send;
}
#[cfg(test)]
mod tests {
use crate::locking::{Lock, LockingResult, SimpleLock};
use crate::locking::{Lock, SimpleLock};
const LOCK_ID: &str = "1";
const RESULT: &str = "result";
#[tokio::test]
async fn test_locking() {
@ -94,22 +52,4 @@ mod tests {
lock.unlock(LOCK_ID).await;
assert!(lock.lock(LOCK_ID).await);
}
#[tokio::test]
async fn test_run_if_not_locked() {
let lock = SimpleLock::new();
assert_eq!(
LockingResult::SUCCESS(RESULT),
lock.run_if_not_locked(LOCK_ID, test_fn).await
);
assert!(lock.lock(LOCK_ID).await);
assert_eq!(
LockingResult::LOCKED,
lock.run_if_not_locked(LOCK_ID, test_fn).await
);
}
async fn test_fn() -> &'static str {
RESULT
}
}

View File

@ -1,9 +1,13 @@
use crate::db::repository::Repository;
use crate::files;
use crate::files::download;
use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::{response::Html, routing::get, Router};
use files::list::query_file_tree;
use problem_details::ProblemDetails;
pub fn routes() -> Router<AppState> {
Router::new()
@ -13,8 +17,48 @@ pub fn routes() -> Router<AppState> {
post(files::upload::handle_file_uploads).get(query_file_tree),
)
.route("/api/v1/user/{:user_id}/download", get(download::handler))
.route(
"/api/v1/user/{:user_id}",
post(create_user).get(get_user).delete(delete_user),
)
}
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
async fn create_user(
State(state): State<AppState>,
axum::extract::Path(user_id): axum::extract::Path<String>,
) -> Result<(), ProblemDetails> {
if state.sqlite.get_user(&user_id).await?.is_some() {
Err(ProblemDetails::from_status_code(StatusCode::CONFLICT)
.with_detail("User already exists".to_string()))
} else {
state.sqlite.create_user(&user_id).await?;
Ok(())
}
}
async fn get_user(
State(state): State<AppState>,
axum::extract::Path(user_id): axum::extract::Path<String>,
) -> Result<(), ProblemDetails> {
if state.sqlite.get_user(&user_id).await?.is_none() {
Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND))
} else {
Ok(())
}
}
async fn delete_user(
State(state): State<AppState>,
axum::extract::Path(user_id): axum::extract::Path<String>,
) -> Result<(), ProblemDetails> {
if state.sqlite.get_user(&user_id).await?.is_none() {
Ok(())
} else {
state.get_repository().delete_user(&user_id).await;
Ok(())
}
}

View File

@ -1,7 +1,7 @@
meta {
name: Auth
type: http
seq: 5
seq: 1
}
get {

View File

@ -0,0 +1,15 @@
meta {
name: No Auth Header
type: http
seq: 3
}
get {
url: {{casket_host}}/api/v1/user/test123/files
body: none
auth: none
}
assert {
res.status: eq 401
}

View File

@ -0,0 +1,19 @@
meta {
name: Not Matching User Id
type: http
seq: 2
}
get {
url: {{casket_host}}/api/v1/user/test123/files
body: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
}
assert {
res.status: eq 403
}

View File

@ -1,7 +1,7 @@
meta {
name: Download File
type: http
seq: 4
seq: 3
}
get {

View File

@ -0,0 +1,46 @@
meta {
name: Get Files
type: http
seq: 2
}
get {
url: {{casket_host}}/api/v1/user/{{user_id}}/files?path=&nesting=5
body: none
auth: bearer
}
params:query {
path:
nesting: 5
}
auth:bearer {
token: {{access_token}}
}
assert {
res.status: eq 200
}
tests {
test("body should be correct", function() {
const data = res.getBody();
expect(data).to.eql({
"files": [
{
"name": "test",
"is_dir": true,
"children": [
{
"name": "blub.txt",
"is_dir": false,
"children": []
}
]
}
]
})
})
}

View File

@ -1,7 +1,7 @@
meta {
name: Upload File
type: http
seq: 2
seq: 1
}
post {

View File

@ -1,20 +0,0 @@
meta {
name: Get Files
type: http
seq: 3
}
get {
url: {{casket_host}}/api/v1/user/{{user_id}}/files?path=&nesting=5
body: none
auth: bearer
}
params:query {
path:
nesting: 5
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,19 @@
meta {
name: Create User
type: http
seq: 2
}
post {
url: {{casket_host}}/api/v1/user/{{user_id}}
body: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
}
assert {
res.status: eq 200
}

View File

@ -0,0 +1,11 @@
meta {
name: Delete User
type: http
seq: 3
}
delete {
url: {{casket_host}}/api/v1/user/{{user_id}}
body: none
auth: inherit
}

View File

@ -1,11 +1,11 @@
meta {
name: Invalid
name: Get User
type: http
seq: 6
seq: 1
}
get {
url: {{casket_host}}/api/v1/user//files
url: {{casket_host}}/api/v1/user/{{user_id}}
body: none
auth: bearer
}

View File

@ -0,0 +1,7 @@
auth {
mode: bearer
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,7 @@
vars {
casket_host: http://localhost:3000
}
vars:secret [
keycloak_host,
keycloak_realm
]