view image files

This commit is contained in:
2025-07-23 13:47:42 +02:00
parent b3e68deb38
commit 7f8726c225
23 changed files with 553 additions and 45 deletions

2
backend/Cargo.lock generated
View File

@@ -2518,6 +2518,7 @@ dependencies = [
"argon2", "argon2",
"axum", "axum",
"axum_typed_multipart", "axum_typed_multipart",
"base64",
"chrono", "chrono",
"derive_more", "derive_more",
"dotenv", "dotenv",
@@ -2530,6 +2531,7 @@ dependencies = [
"sqlx", "sqlx",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tracing", "tracing",

View File

@@ -16,6 +16,7 @@ anyhow = "1.0.98"
argon2 = "0.5.3" argon2 = "0.5.3"
axum = { version = "0.8.4", features = ["multipart", "query"] } axum = { version = "0.8.4", features = ["multipart", "query"] }
axum_typed_multipart = "0.16.3" axum_typed_multipart = "0.16.3"
base64 = "0.22.1"
chrono = "0.4.41" chrono = "0.4.41"
derive_more = { version = "2.0.1", features = ["display"] } derive_more = { version = "2.0.1", features = ["display"] }
dotenv = "0.15.0" dotenv = "0.15.0"
@@ -28,6 +29,7 @@ serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio", "time", "uuid"] } sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio", "time", "uuid"] }
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] } tokio = { version = "1.46.1", features = ["full"] }
tokio-util = "0.7.15"
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] } tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] }
tracing = "0.1.41" tracing = "0.1.41"

View File

@@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> {
PostgresConfig::new(config.database_url.clone(), config.database_name.clone()); PostgresConfig::new(config.database_url.clone(), config.database_name.clone());
let postgres = Postgres::new(postgres_config).await?; let postgres = Postgres::new(postgres_config).await?;
let fs_config = FileSystemConfig::new(config.serve_dir); let fs_config = FileSystemConfig::from_env(config.serve_dir.clone())?;
let fs = FileSystem::new(fs_config)?; let fs = FileSystem::new(fs_config)?;
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier); let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);

View File

@@ -1,5 +1,6 @@
mod requests; mod requests;
pub use requests::*; pub use requests::*;
use tokio_util::io::ReaderStream;
use std::path::Path; use std::path::Path;
@@ -227,3 +228,22 @@ impl From<AbsoluteFilePath> for FilePath {
Self(value.0) Self(value.0)
} }
} }
#[derive(Debug)]
pub struct FileStream(ReaderStream<tokio::fs::File>);
impl FileStream {
pub fn new(stream: ReaderStream<tokio::fs::File>) -> Self {
Self(stream)
}
pub fn stream(&self) -> &ReaderStream<tokio::fs::File> {
&self.0
}
}
impl From<FileStream> for ReaderStream<tokio::fs::File> {
fn from(value: FileStream) -> Self {
value.0
}
}

View File

@@ -150,3 +150,26 @@ pub enum RenameEntryError {
#[error(transparent)] #[error(transparent)]
Unknown(#[from] anyhow::Error), Unknown(#[from] anyhow::Error),
} }
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FetchFileRequest {
path: AbsoluteFilePath,
}
impl FetchFileRequest {
pub fn new(path: AbsoluteFilePath) -> Self {
Self { path }
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
}
}
#[derive(Debug, Error)]
pub enum FetchFileError {
#[error("The file does not exist")]
NotFound,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -4,8 +4,8 @@ use uuid::Uuid;
use crate::domain::warren::models::file::{ use crate::domain::warren::models::file::{
AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError,
CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError,
DeleteFileRequest, File, FileName, FilePath, ListFilesError, ListFilesRequest, DeleteFileRequest, FetchFileError, FetchFileRequest, File, FileName, FilePath, ListFilesError,
RelativeFilePath, RenameEntryError, RenameEntryRequest, ListFilesRequest, RelativeFilePath, RenameEntryError, RenameEntryRequest,
}; };
use super::{Warren, WarrenName}; use super::{Warren, WarrenName};
@@ -601,3 +601,44 @@ pub enum DeleteWarrenError {
#[error(transparent)] #[error(transparent)]
Unknown(#[from] anyhow::Error), Unknown(#[from] anyhow::Error),
} }
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FetchWarrenFileRequest {
warren_id: Uuid,
path: AbsoluteFilePath,
}
impl FetchWarrenFileRequest {
pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self {
Self { warren_id, path }
}
pub fn warren_id(&self) -> &Uuid {
&self.warren_id
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
}
pub fn to_fs_request(self, warren: &Warren) -> FetchFileRequest {
let path = warren.path().clone().join(&self.path.to_relative());
FetchFileRequest::new(path)
}
}
impl Into<FetchWarrenRequest> for &FetchWarrenFileRequest {
fn into(self) -> FetchWarrenRequest {
FetchWarrenRequest::new(self.warren_id)
}
}
#[derive(Debug, Error)]
pub enum FetchWarrenFileError {
#[error(transparent)]
FetchWarren(#[from] FetchWarrenError),
#[error(transparent)]
Fs(#[from] FetchFileError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -20,6 +20,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static {
fn record_list_warren_files_success(&self) -> impl Future<Output = ()> + Send; fn record_list_warren_files_success(&self) -> impl Future<Output = ()> + Send;
fn record_list_warren_files_failure(&self) -> impl Future<Output = ()> + Send; fn record_list_warren_files_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_fetch_file_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_fetch_file_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_directory_creation_success(&self) -> impl Future<Output = ()> + Send; fn record_warren_directory_creation_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_directory_creation_failure(&self) -> impl Future<Output = ()> + Send; fn record_warren_directory_creation_failure(&self) -> impl Future<Output = ()> + Send;
@@ -54,6 +57,10 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
fn record_file_creation_success(&self) -> impl Future<Output = ()> + Send; fn record_file_creation_success(&self) -> impl Future<Output = ()> + Send;
fn record_file_creation_failure(&self) -> impl Future<Output = ()> + Send; fn record_file_creation_failure(&self) -> impl Future<Output = ()> + Send;
fn record_file_fetch_success(&self) -> impl Future<Output = ()> + Send;
fn record_file_fetch_failure(&self) -> impl Future<Output = ()> + Send;
fn record_file_deletion_success(&self) -> impl Future<Output = ()> + Send; fn record_file_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_file_deletion_failure(&self) -> impl Future<Output = ()> + Send; fn record_file_deletion_failure(&self) -> impl Future<Output = ()> + Send;
@@ -125,6 +132,9 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static {
fn record_auth_warren_directory_deletion_success(&self) -> impl Future<Output = ()> + Send; fn record_auth_warren_directory_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_directory_deletion_failure(&self) -> impl Future<Output = ()> + Send; fn record_auth_warren_directory_deletion_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_fetch_file_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_fetch_file_failure(&self) -> impl Future<Output = ()> + Send;
/// An upload succeeded fully /// An upload succeeded fully
fn record_auth_warren_files_upload_success(&self) -> impl Future<Output = ()> + Send; fn record_auth_warren_files_upload_success(&self) -> impl Future<Output = ()> + Send;
/// An upload failed at least partially /// An upload failed at least partially

View File

@@ -16,8 +16,9 @@ use super::models::{
}, },
file::{ file::{
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError,
ListFilesRequest, RenameEntryError, RenameEntryRequest,
}, },
user::{ user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
@@ -39,10 +40,11 @@ use super::models::{
DeleteWarrenDirectoryRequest, DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenDirectoryRequest, DeleteWarrenDirectoryResponse, DeleteWarrenError,
DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenFileResponse, DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenFileResponse,
DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError,
FetchWarrenRequest, FetchWarrensError, FetchWarrensRequest, ListWarrenFilesError, FetchWarrenFileError, FetchWarrenFileRequest, FetchWarrenRequest, FetchWarrensError,
ListWarrenFilesRequest, ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, FetchWarrensRequest, ListWarrenFilesError, ListWarrenFilesRequest, ListWarrenFilesResponse,
RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse, ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest,
UploadWarrenFilesError, UploadWarrenFilesRequest, UploadWarrenFilesResponse, Warren, RenameWarrenEntryResponse, UploadWarrenFilesError, UploadWarrenFilesRequest,
UploadWarrenFilesResponse, Warren,
}, },
}; };
@@ -73,6 +75,11 @@ pub trait WarrenService: Clone + Send + Sync + 'static {
request: FetchWarrenRequest, request: FetchWarrenRequest,
) -> impl Future<Output = Result<Warren, FetchWarrenError>> + Send; ) -> impl Future<Output = Result<Warren, FetchWarrenError>> + Send;
fn fetch_warren_file(
&self,
request: FetchWarrenFileRequest,
) -> impl Future<Output = Result<FileStream, FetchWarrenFileError>> + Send;
fn list_warren_files( fn list_warren_files(
&self, &self,
request: ListWarrenFilesRequest, request: ListWarrenFilesRequest,
@@ -122,6 +129,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
&self, &self,
request: CreateFileRequest, request: CreateFileRequest,
) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send; ) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send;
fn fetch_file(
&self,
request: FetchFileRequest,
) -> impl Future<Output = Result<FileStream, FetchFileError>> + Send;
fn delete_file( fn delete_file(
&self, &self,
request: DeleteFileRequest, request: DeleteFileRequest,
@@ -234,6 +245,12 @@ pub trait AuthService: Clone + Send + Sync + 'static {
warren_service: &WS, warren_service: &WS,
) -> impl Future<Output = Result<Warren, AuthError<FetchWarrenError>>> + Send; ) -> impl Future<Output = Result<Warren, AuthError<FetchWarrenError>>> + Send;
fn fetch_warren_file<WS: WarrenService>(
&self,
request: AuthRequest<FetchWarrenFileRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<FileStream, AuthError<FetchWarrenFileError>>> + Send;
fn list_warren_files<WS: WarrenService>( fn list_warren_files<WS: WarrenService>(
&self, &self,
request: AuthRequest<ListWarrenFilesRequest>, request: AuthRequest<ListWarrenFilesRequest>,

View File

@@ -2,7 +2,7 @@ use uuid::Uuid;
use crate::domain::warren::models::{ use crate::domain::warren::models::{
auth_session::requests::FetchAuthSessionResponse, auth_session::requests::FetchAuthSessionResponse,
file::{File, FilePath}, file::{AbsoluteFilePath, File, FilePath},
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
user_warren::UserWarren, user_warren::UserWarren,
warren::{ warren::{
@@ -31,6 +31,12 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static {
&self, &self,
response: &DeleteWarrenDirectoryResponse, response: &DeleteWarrenDirectoryResponse,
) -> impl Future<Output = ()> + Send; ) -> impl Future<Output = ()> + Send;
fn warren_file_fetched(
&self,
warren: &Warren,
path: &AbsoluteFilePath,
) -> impl Future<Output = ()> + Send;
/// A single file was uploaded /// A single file was uploaded
/// ///
/// * `warren`: The warren the file was uploaded to /// * `warren`: The warren the file was uploaded to
@@ -66,6 +72,7 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
fn directory_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send; fn directory_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
fn file_created(&self, path: &FilePath) -> impl Future<Output = ()> + Send; fn file_created(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
fn file_fetched(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
fn file_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send; fn file_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
fn entry_renamed( fn entry_renamed(
@@ -153,6 +160,14 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
user: &User, user: &User,
response: &DeleteWarrenDirectoryResponse, response: &DeleteWarrenDirectoryResponse,
) -> impl Future<Output = ()> + Send; ) -> impl Future<Output = ()> + Send;
fn auth_warren_file_fetched(
&self,
user: &User,
warren_id: &Uuid,
path: &AbsoluteFilePath,
) -> impl Future<Output = ()> + Send;
/// A collection of files was uploaded /// A collection of files was uploaded
/// ///
/// * `warren`: The warren the file was uploaded to /// * `warren`: The warren the file was uploaded to

View File

@@ -8,8 +8,9 @@ use crate::domain::warren::models::{
}, },
file::{ file::{
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError,
ListFilesRequest, RenameEntryError, RenameEntryRequest,
}, },
user::{ user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
@@ -84,6 +85,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
&self, &self,
request: CreateFileRequest, request: CreateFileRequest,
) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send; ) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send;
fn fetch_file(
&self,
request: FetchFileRequest,
) -> impl Future<Output = Result<FileStream, FetchFileError>> + Send;
fn delete_file( fn delete_file(
&self, &self,
request: DeleteFileRequest, request: DeleteFileRequest,

View File

@@ -9,6 +9,7 @@ use crate::{
FetchAuthSessionRequest, FetchAuthSessionResponse, SessionExpirationTime, FetchAuthSessionRequest, FetchAuthSessionResponse, SessionExpirationTime,
}, },
}, },
file::FileStream,
user::{ user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest,
EditUserError, EditUserRequest, ListAllUsersAndWarrensError, EditUserError, EditUserRequest, ListAllUsersAndWarrensError,
@@ -30,11 +31,12 @@ use crate::{
DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest, DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest,
DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileError, DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileError,
DeleteWarrenFileRequest, DeleteWarrenFileResponse, DeleteWarrenRequest, DeleteWarrenFileRequest, DeleteWarrenFileResponse, DeleteWarrenRequest,
EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenFileError,
FetchWarrensRequest, ListWarrenFilesError, ListWarrenFilesRequest, FetchWarrenFileRequest, FetchWarrenRequest, FetchWarrensRequest,
ListWarrenFilesResponse, RenameWarrenEntryError, RenameWarrenEntryRequest, ListWarrenFilesError, ListWarrenFilesRequest, ListWarrenFilesResponse,
RenameWarrenEntryResponse, UploadWarrenFilesError, UploadWarrenFilesRequest, RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse,
UploadWarrenFilesResponse, Warren, UploadWarrenFilesError, UploadWarrenFilesRequest, UploadWarrenFilesResponse,
Warren,
}, },
}, },
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService}, ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService},
@@ -580,6 +582,47 @@ where
result.map_err(|e| AuthError::Custom(e.into())) result.map_err(|e| AuthError::Custom(e.into()))
} }
async fn fetch_warren_file<WS: WarrenService>(
&self,
request: AuthRequest<FetchWarrenFileRequest>,
warren_service: &WS,
) -> Result<FileStream, AuthError<FetchWarrenFileError>> {
let (session, request) = request.unpack();
let path = request.path().clone();
let session_response = self
.fetch_auth_session(FetchAuthSessionRequest::new(session.session_id().clone()))
.await?;
let user_warren = self
.repository
.fetch_user_warren(FetchUserWarrenRequest::new(
session_response.user().id().clone(),
request.warren_id().clone(),
))
.await?;
if !user_warren.can_read_files() {
return Err(AuthError::InsufficientPermissions);
}
let result = warren_service
.fetch_warren_file(request)
.await
.map_err(AuthError::Custom);
if let Ok(_stream) = result.as_ref() {
self.metrics.record_auth_warren_fetch_file_success().await;
self.notifier
.auth_warren_file_fetched(session_response.user(), user_warren.warren_id(), &path)
.await;
} else {
self.metrics.record_auth_warren_fetch_file_failure().await;
}
result
}
async fn list_warren_files<WS: WarrenService>( async fn list_warren_files<WS: WarrenService>(
&self, &self,
request: AuthRequest<ListWarrenFilesRequest>, request: AuthRequest<ListWarrenFilesRequest>,

View File

@@ -1,8 +1,9 @@
use crate::domain::warren::{ use crate::domain::warren::{
models::file::{ models::file::{
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError,
ListFilesRequest, RenameEntryError, RenameEntryRequest,
}, },
ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService}, ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService},
}; };
@@ -98,6 +99,20 @@ where
result result
} }
async fn fetch_file(&self, request: FetchFileRequest) -> Result<FileStream, FetchFileError> {
let path = request.path().clone();
let result = self.repository.fetch_file(request).await;
if let Ok(_stream) = result.as_ref() {
self.metrics.record_file_fetch_success().await;
self.notifier.file_fetched(&path).await;
} else {
self.metrics.record_file_fetch_failure().await;
}
result
}
async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> { async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> {
let result = self.repository.delete_file(request).await; let result = self.repository.delete_file(request).await;

View File

@@ -1,11 +1,14 @@
use crate::domain::warren::{ use crate::domain::warren::{
models::warren::{ models::{
CreateWarrenDirectoryResponse, CreateWarrenError, CreateWarrenRequest, file::FileStream,
DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileResponse, warren::{
DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrensError, CreateWarrenDirectoryResponse, CreateWarrenError, CreateWarrenRequest,
FetchWarrensRequest, ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileResponse,
RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenFileError,
UploadWarrenFilesResponse, FetchWarrenFileRequest, FetchWarrensError, FetchWarrensRequest,
ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, RenameWarrenEntryError,
RenameWarrenEntryRequest, RenameWarrenEntryResponse, UploadWarrenFilesResponse,
},
}, },
ports::FileSystemService, ports::FileSystemService,
}; };
@@ -146,6 +149,30 @@ where
result result
} }
async fn fetch_warren_file(
&self,
request: FetchWarrenFileRequest,
) -> Result<FileStream, FetchWarrenFileError> {
let warren = self.repository.fetch_warren((&request).into()).await?;
let path = request.path().clone();
let result = self
.fs_service
.fetch_file(request.to_fs_request(&warren))
.await
.map_err(Into::into);
if let Ok(_stream) = result.as_ref() {
self.metrics.record_warren_fetch_file_success().await;
self.notifier.warren_file_fetched(&warren, &path).await;
} else {
self.metrics.record_warren_fetch_file_failure().await;
}
result
}
async fn list_warren_files( async fn list_warren_files(
&self, &self,
request: ListWarrenFilesRequest, request: ListWarrenFilesRequest,

View File

@@ -0,0 +1,102 @@
use axum::{
body::Body,
extract::{Query, State},
};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio_util::io::ReaderStream;
use uuid::Uuid;
use crate::{
domain::warren::{
models::{
auth_session::AuthRequest,
file::{AbsoluteFilePathError, FilePath, FilePathError, FileStream},
warren::FetchWarrenFileRequest,
},
ports::{AuthService, WarrenService},
},
inbound::http::{AppState, handlers::extractors::SessionIdHeader, responses::ApiError},
};
#[derive(Debug, Clone, Serialize, PartialEq, Hash)]
#[serde(rename_all = "camelCase")]
pub(super) struct FetchWarrenFileHttpResponseBody {
// base64 encoded file content bytes
contents: String,
}
impl From<Vec<u8>> for FetchWarrenFileHttpResponseBody {
fn from(value: Vec<u8>) -> Self {
Self {
contents: base64::prelude::BASE64_STANDARD.encode(&value),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchWarrenFileHttpRequestBody {
warren_id: Uuid,
path: String,
}
#[derive(Debug, Clone, Error)]
pub enum ParseFetchWarrenFileHttpRequestError {
#[error(transparent)]
FilePath(#[from] FilePathError),
#[error(transparent)]
AbsoluteFilePath(#[from] AbsoluteFilePathError),
}
impl From<ParseFetchWarrenFileHttpRequestError> for ApiError {
fn from(value: ParseFetchWarrenFileHttpRequestError) -> Self {
match value {
ParseFetchWarrenFileHttpRequestError::FilePath(err) => match err {
FilePathError::InvalidPath => {
ApiError::BadRequest("The file path must be valid".to_string())
}
},
ParseFetchWarrenFileHttpRequestError::AbsoluteFilePath(err) => match err {
AbsoluteFilePathError::NotAbsolute => {
ApiError::BadRequest("The file path must be absolute".to_string())
}
},
}
}
}
impl FetchWarrenFileHttpRequestBody {
fn try_into_domain(
self,
) -> Result<FetchWarrenFileRequest, ParseFetchWarrenFileHttpRequestError> {
let path = FilePath::new(&self.path)?.try_into()?;
Ok(FetchWarrenFileRequest::new(self.warren_id, path))
}
}
impl From<FileStream> for Body {
fn from(value: FileStream) -> Self {
Body::from_stream::<ReaderStream<tokio::fs::File>>(value.into())
}
}
pub async fn fetch_file<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Query(request): Query<FetchWarrenFileHttpRequestBody>,
) -> Result<Body, ApiError> {
let domain_request = request.try_into_domain()?;
state
.auth_service
.fetch_warren_file(
AuthRequest::new(session, domain_request),
state.warren_service.as_ref(),
)
.await
.map(|contents| contents.into())
.map_err(ApiError::from)
}

View File

@@ -1,6 +1,7 @@
mod create_warren_directory; mod create_warren_directory;
mod delete_warren_directory; mod delete_warren_directory;
mod delete_warren_file; mod delete_warren_file;
mod fetch_file;
mod fetch_warren; mod fetch_warren;
mod list_warren_files; mod list_warren_files;
mod list_warrens; mod list_warrens;
@@ -26,6 +27,7 @@ use create_warren_directory::create_warren_directory;
use delete_warren_directory::delete_warren_directory; use delete_warren_directory::delete_warren_directory;
use delete_warren_file::delete_warren_file; use delete_warren_file::delete_warren_file;
use fetch_file::fetch_file;
use rename_warren_entry::rename_warren_entry; use rename_warren_entry::rename_warren_entry;
use upload_warren_files::upload_warren_files; use upload_warren_files::upload_warren_files;
@@ -33,6 +35,7 @@ pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
Router::new() Router::new()
.route("/", get(list_warrens)) .route("/", get(list_warrens))
.route("/", post(fetch_warren)) .route("/", post(fetch_warren))
.route("/files/fetch", get(fetch_file))
.route("/files", post(list_warren_files)) .route("/files", post(list_warren_files))
.route("/files/directory", post(create_warren_directory)) .route("/files/directory", post(create_warren_directory))
.route("/files/directory", delete(delete_warren_directory)) .route("/files/directory", delete(delete_warren_directory))

View File

@@ -1,40 +1,65 @@
use std::os::unix::fs::MetadataExt;
use anyhow::{Context, anyhow, bail}; use anyhow::{Context, anyhow, bail};
use rustix::fs::statx; use rustix::fs::statx;
use tokio::{ use tokio::{
fs, fs,
io::{self, AsyncWriteExt as _}, io::{self, AsyncWriteExt as _},
}; };
use tokio_util::io::ReaderStream;
use crate::domain::warren::{ use crate::{
models::file::{ config::Config,
AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, domain::warren::{
CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, models::file::{
DeleteFileRequest, File, FileMimeType, FileName, FilePath, FileType, ListFilesError, AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError,
ListFilesRequest, RenameEntryError, RenameEntryRequest, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError,
DeleteFileRequest, FetchFileError, FetchFileRequest, File, FileMimeType, FileName,
FilePath, FileStream, FileType, ListFilesError, ListFilesRequest, RenameEntryError,
RenameEntryRequest,
},
ports::FileSystemRepository,
}, },
ports::FileSystemRepository,
}; };
const MAX_FILE_FETCH_BYTES: &str = "MAX_FILE_FETCH_BYTES";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileSystemConfig { pub struct FileSystemConfig {
base_directory: String, base_directory: String,
max_file_fetch_bytes: u64,
} }
impl FileSystemConfig { impl FileSystemConfig {
pub fn new(base_directory: String) -> Self { pub fn new(base_directory: String, max_file_fetch_bytes: u64) -> Self {
Self { base_directory } Self {
base_directory,
max_file_fetch_bytes,
}
}
pub fn from_env(serve_dir: String) -> anyhow::Result<Self> {
// 268435456 bytes = 0.25GB
let max_file_fetch_bytes: u64 = match Config::load_env(MAX_FILE_FETCH_BYTES) {
Ok(value) => value.parse()?,
Err(_) => 268435456,
};
Ok(Self::new(serve_dir, max_file_fetch_bytes))
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileSystem { pub struct FileSystem {
base_directory: FilePath, base_directory: FilePath,
max_file_fetch_bytes: u64,
} }
impl FileSystem { impl FileSystem {
pub fn new(config: FileSystemConfig) -> anyhow::Result<Self> { pub fn new(config: FileSystemConfig) -> anyhow::Result<Self> {
let file_system = Self { let file_system = Self {
base_directory: FilePath::new(&config.base_directory)?, base_directory: FilePath::new(&config.base_directory)?,
max_file_fetch_bytes: config.max_file_fetch_bytes,
}; };
Ok(file_system) Ok(file_system)
@@ -146,6 +171,27 @@ impl FileSystem {
Ok(path) Ok(path)
} }
async fn fetch_file(&self, path: &AbsoluteFilePath) -> anyhow::Result<FileStream> {
let path = self.get_target_path(path);
let file = fs::OpenOptions::new()
.create(false)
.write(false)
.read(true)
.open(&path)
.await?;
let file_size = file.metadata().await?.size();
if file_size > self.max_file_fetch_bytes {
bail!("File size exceeds configured limit");
}
let stream = FileStream::new(ReaderStream::new(file));
Ok(stream)
}
/// Actually removes a file from the underlying file system /// Actually removes a file from the underlying file system
/// ///
/// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`) /// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`)
@@ -237,6 +283,15 @@ impl FileSystemRepository for FileSystem {
Ok(file_path) Ok(file_path)
} }
async fn fetch_file(&self, request: FetchFileRequest) -> Result<FileStream, FetchFileError> {
let contents = self
.fetch_file(request.path())
.await
.map_err(|e| anyhow!("Failed to fetch file {}: {e:?}", request.path()))?;
Ok(contents)
}
async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> { async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> {
let deleted_path = self let deleted_path = self
.remove_file(request.path()) .remove_file(request.path())

View File

@@ -52,6 +52,13 @@ impl WarrenMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] Fetch warrens failed"); tracing::debug!("[Metrics] Fetch warrens failed");
} }
async fn record_warren_fetch_file_success(&self) {
tracing::debug!("[Metrics] Fetch warren file succeeded");
}
async fn record_warren_fetch_file_failure(&self) {
tracing::debug!("[Metrics] Fetch warren file failed");
}
async fn record_list_warren_files_success(&self) { async fn record_list_warren_files_success(&self) {
tracing::debug!("[Metrics] Warren list files succeeded"); tracing::debug!("[Metrics] Warren list files succeeded");
} }
@@ -134,6 +141,13 @@ impl FileSystemMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] File creation failed"); tracing::debug!("[Metrics] File creation failed");
} }
async fn record_file_fetch_success(&self) {
tracing::debug!("[Metrics] File fetch succeeded");
}
async fn record_file_fetch_failure(&self) {
tracing::debug!("[Metrics] File fetch failed");
}
async fn record_file_deletion_success(&self) { async fn record_file_deletion_success(&self) {
tracing::debug!("[Metrics] File deletion succeeded"); tracing::debug!("[Metrics] File deletion succeeded");
} }
@@ -255,6 +269,13 @@ impl AuthMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] User warren deletion failed"); tracing::debug!("[Metrics] User warren deletion failed");
} }
async fn record_auth_warren_fetch_file_success(&self) {
tracing::debug!("[Metrics] Warren file fetch succeeded");
}
async fn record_auth_warren_fetch_file_failure(&self) {
tracing::debug!("[Metrics] Warren file fetch failed");
}
async fn record_auth_fetch_user_warren_list_success(&self) { async fn record_auth_fetch_user_warren_list_success(&self) {
tracing::debug!("[Metrics] Auth warren list succeeded"); tracing::debug!("[Metrics] Auth warren list succeeded");
} }

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use crate::domain::warren::{ use crate::domain::warren::{
models::{ models::{
auth_session::requests::FetchAuthSessionResponse, auth_session::requests::FetchAuthSessionResponse,
file::{File, FilePath}, file::{AbsoluteFilePath, File, FilePath},
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
user_warren::UserWarren, user_warren::UserWarren,
warren::{ warren::{
@@ -46,6 +46,14 @@ impl WarrenNotifier for NotifierDebugLogger {
tracing::debug!("[Notifier] Fetched warren {}", warren.name()); tracing::debug!("[Notifier] Fetched warren {}", warren.name());
} }
async fn warren_file_fetched(&self, warren: &Warren, path: &AbsoluteFilePath) {
tracing::debug!(
"[Notifier] Fetched file {} in warren {}",
path,
warren.name(),
);
}
async fn warren_files_listed(&self, response: &ListWarrenFilesResponse) { async fn warren_files_listed(&self, response: &ListWarrenFilesResponse) {
tracing::debug!( tracing::debug!(
"[Notifier] Listed {} file(s) in warren {}", "[Notifier] Listed {} file(s) in warren {}",
@@ -121,6 +129,10 @@ impl FileSystemNotifier for NotifierDebugLogger {
tracing::debug!("[Notifier] Created file {}", path); tracing::debug!("[Notifier] Created file {}", path);
} }
async fn file_fetched(&self, path: &AbsoluteFilePath) {
tracing::debug!("[Notifier] Fetched file {path}");
}
async fn file_deleted(&self, path: &FilePath) { async fn file_deleted(&self, path: &FilePath) {
tracing::debug!("[Notifier] Deleted file {}", path); tracing::debug!("[Notifier] Deleted file {}", path);
} }
@@ -269,6 +281,18 @@ impl AuthNotifier for NotifierDebugLogger {
); );
} }
async fn auth_warren_file_fetched(
&self,
user: &User,
warren_id: &Uuid,
path: &AbsoluteFilePath,
) {
tracing::debug!(
"[Notifier] User {} fetched file {path} in warren {warren_id}",
user.id(),
);
}
async fn auth_warren_files_listed(&self, user: &User, response: &ListWarrenFilesResponse) { async fn auth_warren_files_listed(&self, user: &User, response: &ListWarrenFilesResponse) {
tracing::debug!( tracing::debug!(
"[Notifier] Listed {} file(s) in warren {} for authenticated user {}", "[Notifier] Listed {} file(s) in warren {} for authenticated user {}",

View File

@@ -6,7 +6,11 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
} from '@/components/ui/context-menu'; } from '@/components/ui/context-menu';
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens'; import {
deleteWarrenDirectory,
deleteWarrenFile,
fetchFile,
} from '~/lib/api/warrens';
import type { DirectoryEntry } from '#shared/types'; import type { DirectoryEntry } from '#shared/types';
const warrenStore = useWarrenStore(); const warrenStore = useWarrenStore();
@@ -49,11 +53,30 @@ async function openRenameDialog() {
} }
async function onClick() { async function onClick() {
if (warrenStore.loading) { if (warrenStore.loading || warrenStore.current == null) {
return; return;
} }
warrenStore.addToCurrentWarrenPath(entry.name); if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
} }
</script> </script>
@@ -62,12 +85,7 @@ async function onClick() {
<ContextMenuTrigger> <ContextMenuTrigger>
<button <button
:disabled="warrenStore.loading || disabled" :disabled="warrenStore.loading || disabled"
:class="[ class="bg-accent/30 border-border select-none, flex w-52 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2"
'bg-accent/30 border-border flex w-52 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
{
'pointer-events-none': entry.fileType === 'file',
},
]"
@click="onClick" @click="onClick"
> >
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
const warrenStore = useWarrenStore();
function onOpenUpdate(state: boolean) {
if (!state) {
warrenStore.imageViewer.src = null;
}
}
</script>
<template>
<Dialog
:open="warrenStore.imageViewer.src != null"
@update:open="onOpenUpdate"
>
<DialogTrigger>
<slot />
</DialogTrigger>
<DialogContent
class="w-full overflow-hidden p-0 sm:!max-h-[90vh] sm:!max-w-[90vw]"
>
<img
v-if="warrenStore.imageViewer.src"
class="h-full w-full overflow-hidden !object-contain"
:src="warrenStore.imageViewer.src"
/>
</DialogContent>
</Dialog>
</template>

View File

@@ -16,6 +16,7 @@ await useAsyncData('warrens', async () => {
<template> <template>
<SidebarProvider> <SidebarProvider>
<ImageViewer />
<AppSidebar /> <AppSidebar />
<SidebarInset class="flex flex-col-reverse md:flex-col"> <SidebarInset class="flex flex-col-reverse md:flex-col">
<header <header

View File

@@ -265,3 +265,35 @@ export async function renameWarrenEntry(
return { success: true }; return { success: true };
} }
export async function fetchFile(
warrenId: string,
path: string,
fileName: string
): Promise<{ success: true; data: Blob } | { success: false }> {
if (!path.endsWith('/')) {
path += '/';
}
path += fileName;
const { data, error } = await useFetch<Blob>(
getApiUrl(`warrens/files/fetch?warrenId=${warrenId}&path=${path}`),
{
method: 'GET',
headers: getApiHeaders(),
responseType: 'blob',
cache: 'default',
}
);
if (data.value == null || error.value != null) {
return {
success: false,
};
}
return {
success: true,
data: data.value,
};
}

View File

@@ -5,6 +5,9 @@ import type { WarrenData } from '#shared/types/warrens';
export const useWarrenStore = defineStore('warrens', { export const useWarrenStore = defineStore('warrens', {
state: () => ({ state: () => ({
warrens: {} as Record<string, WarrenData>, warrens: {} as Record<string, WarrenData>,
imageViewer: {
src: null as string | null,
},
current: null as { warrenId: string; path: string } | null, current: null as { warrenId: string; path: string } | null,
loading: false, loading: false,
}), }),