From 7f8726c22528a6b9408bce340960d3a4a855201d Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Wed, 23 Jul 2025 13:47:42 +0200 Subject: [PATCH] view image files --- backend/Cargo.lock | 2 + backend/Cargo.toml | 2 + backend/src/bin/backend/main.rs | 2 +- .../src/lib/domain/warren/models/file/mod.rs | 20 ++++ .../lib/domain/warren/models/file/requests.rs | 23 ++++ .../domain/warren/models/warren/requests.rs | 45 +++++++- .../src/lib/domain/warren/ports/metrics.rs | 10 ++ backend/src/lib/domain/warren/ports/mod.rs | 29 +++-- .../src/lib/domain/warren/ports/notifier.rs | 17 ++- .../src/lib/domain/warren/ports/repository.rs | 9 +- backend/src/lib/domain/warren/service/auth.rs | 53 ++++++++- .../lib/domain/warren/service/file_system.rs | 19 +++- .../src/lib/domain/warren/service/warren.rs | 41 +++++-- .../http/handlers/warrens/fetch_file.rs | 102 ++++++++++++++++++ .../lib/inbound/http/handlers/warrens/mod.rs | 3 + backend/src/lib/outbound/file_system.rs | 73 +++++++++++-- .../src/lib/outbound/metrics_debug_logger.rs | 21 ++++ .../src/lib/outbound/notifier_debug_logger.rs | 26 ++++- frontend/components/DirectoryEntry.vue | 36 +++++-- frontend/components/ImageViewer.vue | 29 +++++ frontend/layouts/default.vue | 1 + frontend/lib/api/warrens.ts | 32 ++++++ frontend/stores/index.ts | 3 + 23 files changed, 553 insertions(+), 45 deletions(-) create mode 100644 backend/src/lib/inbound/http/handlers/warrens/fetch_file.rs create mode 100644 frontend/components/ImageViewer.vue diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d12ebd8..10a8024 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2518,6 +2518,7 @@ dependencies = [ "argon2", "axum", "axum_typed_multipart", + "base64", "chrono", "derive_more", "dotenv", @@ -2530,6 +2531,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tokio-util", "tower", "tower-http", "tracing", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ddd7c8e..8ed98b7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.0.98" argon2 = "0.5.3" axum = { version = "0.8.4", features = ["multipart", "query"] } axum_typed_multipart = "0.16.3" +base64 = "0.22.1" chrono = "0.4.41" derive_more = { version = "2.0.1", features = ["display"] } 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"] } thiserror = "2.0.12" tokio = { version = "1.46.1", features = ["full"] } +tokio-util = "0.7.15" tower = "0.5.2" tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] } tracing = "0.1.41" diff --git a/backend/src/bin/backend/main.rs b/backend/src/bin/backend/main.rs index fbee4da..edb190c 100644 --- a/backend/src/bin/backend/main.rs +++ b/backend/src/bin/backend/main.rs @@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> { PostgresConfig::new(config.database_url.clone(), config.database_name.clone()); 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_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier); diff --git a/backend/src/lib/domain/warren/models/file/mod.rs b/backend/src/lib/domain/warren/models/file/mod.rs index 09409a7..c5e5765 100644 --- a/backend/src/lib/domain/warren/models/file/mod.rs +++ b/backend/src/lib/domain/warren/models/file/mod.rs @@ -1,5 +1,6 @@ mod requests; pub use requests::*; +use tokio_util::io::ReaderStream; use std::path::Path; @@ -227,3 +228,22 @@ impl From for FilePath { Self(value.0) } } + +#[derive(Debug)] +pub struct FileStream(ReaderStream); + +impl FileStream { + pub fn new(stream: ReaderStream) -> Self { + Self(stream) + } + + pub fn stream(&self) -> &ReaderStream { + &self.0 + } +} + +impl From for ReaderStream { + fn from(value: FileStream) -> Self { + value.0 + } +} diff --git a/backend/src/lib/domain/warren/models/file/requests.rs b/backend/src/lib/domain/warren/models/file/requests.rs index 04975a4..336efb9 100644 --- a/backend/src/lib/domain/warren/models/file/requests.rs +++ b/backend/src/lib/domain/warren/models/file/requests.rs @@ -150,3 +150,26 @@ pub enum RenameEntryError { #[error(transparent)] 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), +} diff --git a/backend/src/lib/domain/warren/models/warren/requests.rs b/backend/src/lib/domain/warren/models/warren/requests.rs index 63a9780..ab9a5fd 100644 --- a/backend/src/lib/domain/warren/models/warren/requests.rs +++ b/backend/src/lib/domain/warren/models/warren/requests.rs @@ -4,8 +4,8 @@ use uuid::Uuid; use crate::domain::warren::models::file::{ AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, - DeleteFileRequest, File, FileName, FilePath, ListFilesError, ListFilesRequest, - RelativeFilePath, RenameEntryError, RenameEntryRequest, + DeleteFileRequest, FetchFileError, FetchFileRequest, File, FileName, FilePath, ListFilesError, + ListFilesRequest, RelativeFilePath, RenameEntryError, RenameEntryRequest, }; use super::{Warren, WarrenName}; @@ -601,3 +601,44 @@ pub enum DeleteWarrenError { #[error(transparent)] 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 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), +} diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 9353bba..945e47c 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -20,6 +20,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static { fn record_list_warren_files_success(&self) -> impl Future + Send; fn record_list_warren_files_failure(&self) -> impl Future + Send; + fn record_warren_fetch_file_success(&self) -> impl Future + Send; + fn record_warren_fetch_file_failure(&self) -> impl Future + Send; + fn record_warren_directory_creation_success(&self) -> impl Future + Send; fn record_warren_directory_creation_failure(&self) -> impl Future + Send; @@ -54,6 +57,10 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static { fn record_file_creation_success(&self) -> impl Future + Send; fn record_file_creation_failure(&self) -> impl Future + Send; + + fn record_file_fetch_success(&self) -> impl Future + Send; + fn record_file_fetch_failure(&self) -> impl Future + Send; + fn record_file_deletion_success(&self) -> impl Future + Send; fn record_file_deletion_failure(&self) -> impl Future + Send; @@ -125,6 +132,9 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_auth_warren_directory_deletion_success(&self) -> impl Future + Send; fn record_auth_warren_directory_deletion_failure(&self) -> impl Future + Send; + fn record_auth_warren_fetch_file_success(&self) -> impl Future + Send; + fn record_auth_warren_fetch_file_failure(&self) -> impl Future + Send; + /// An upload succeeded fully fn record_auth_warren_files_upload_success(&self) -> impl Future + Send; /// An upload failed at least partially diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 1dba5f3..524f2f8 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -16,8 +16,9 @@ use super::models::{ }, file::{ CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, - DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, - FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, + DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, + FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError, + ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, @@ -39,10 +40,11 @@ use super::models::{ DeleteWarrenDirectoryRequest, DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenFileResponse, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError, - FetchWarrenRequest, FetchWarrensError, FetchWarrensRequest, ListWarrenFilesError, - ListWarrenFilesRequest, ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, - RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse, - UploadWarrenFilesError, UploadWarrenFilesRequest, UploadWarrenFilesResponse, Warren, + FetchWarrenFileError, FetchWarrenFileRequest, FetchWarrenRequest, FetchWarrensError, + FetchWarrensRequest, ListWarrenFilesError, ListWarrenFilesRequest, ListWarrenFilesResponse, + ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest, + RenameWarrenEntryResponse, UploadWarrenFilesError, UploadWarrenFilesRequest, + UploadWarrenFilesResponse, Warren, }, }; @@ -73,6 +75,11 @@ pub trait WarrenService: Clone + Send + Sync + 'static { request: FetchWarrenRequest, ) -> impl Future> + Send; + fn fetch_warren_file( + &self, + request: FetchWarrenFileRequest, + ) -> impl Future> + Send; + fn list_warren_files( &self, request: ListWarrenFilesRequest, @@ -122,6 +129,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static { &self, request: CreateFileRequest, ) -> impl Future> + Send; + fn fetch_file( + &self, + request: FetchFileRequest, + ) -> impl Future> + Send; fn delete_file( &self, request: DeleteFileRequest, @@ -234,6 +245,12 @@ pub trait AuthService: Clone + Send + Sync + 'static { warren_service: &WS, ) -> impl Future>> + Send; + fn fetch_warren_file( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> impl Future>> + Send; + fn list_warren_files( &self, request: AuthRequest, diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index 8a4050a..c5c66bf 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::domain::warren::models::{ auth_session::requests::FetchAuthSessionResponse, - file::{File, FilePath}, + file::{AbsoluteFilePath, File, FilePath}, user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ @@ -31,6 +31,12 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static { &self, response: &DeleteWarrenDirectoryResponse, ) -> impl Future + Send; + + fn warren_file_fetched( + &self, + warren: &Warren, + path: &AbsoluteFilePath, + ) -> impl Future + Send; /// A single file was uploaded /// /// * `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 + Send; fn file_created(&self, path: &FilePath) -> impl Future + Send; + fn file_fetched(&self, path: &AbsoluteFilePath) -> impl Future + Send; fn file_deleted(&self, path: &FilePath) -> impl Future + Send; fn entry_renamed( @@ -153,6 +160,14 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static { user: &User, response: &DeleteWarrenDirectoryResponse, ) -> impl Future + Send; + + fn auth_warren_file_fetched( + &self, + user: &User, + warren_id: &Uuid, + path: &AbsoluteFilePath, + ) -> impl Future + Send; + /// A collection of files was uploaded /// /// * `warren`: The warren the file was uploaded to diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 938cf52..bc00250 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -8,8 +8,9 @@ use crate::domain::warren::models::{ }, file::{ CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, - DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, - FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, + DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, + FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError, + ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, @@ -84,6 +85,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { &self, request: CreateFileRequest, ) -> impl Future> + Send; + fn fetch_file( + &self, + request: FetchFileRequest, + ) -> impl Future> + Send; fn delete_file( &self, request: DeleteFileRequest, diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 3792219..e04d043 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -9,6 +9,7 @@ use crate::{ FetchAuthSessionRequest, FetchAuthSessionResponse, SessionExpirationTime, }, }, + file::FileStream, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest, ListAllUsersAndWarrensError, @@ -30,11 +31,12 @@ use crate::{ DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest, DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenFileResponse, DeleteWarrenRequest, - EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, - FetchWarrensRequest, ListWarrenFilesError, ListWarrenFilesRequest, - ListWarrenFilesResponse, RenameWarrenEntryError, RenameWarrenEntryRequest, - RenameWarrenEntryResponse, UploadWarrenFilesError, UploadWarrenFilesRequest, - UploadWarrenFilesResponse, Warren, + EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenFileError, + FetchWarrenFileRequest, FetchWarrenRequest, FetchWarrensRequest, + ListWarrenFilesError, ListWarrenFilesRequest, ListWarrenFilesResponse, + RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse, + UploadWarrenFilesError, UploadWarrenFilesRequest, UploadWarrenFilesResponse, + Warren, }, }, ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService}, @@ -580,6 +582,47 @@ where result.map_err(|e| AuthError::Custom(e.into())) } + async fn fetch_warren_file( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> Result> { + 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( &self, request: AuthRequest, diff --git a/backend/src/lib/domain/warren/service/file_system.rs b/backend/src/lib/domain/warren/service/file_system.rs index 2179b61..6927d14 100644 --- a/backend/src/lib/domain/warren/service/file_system.rs +++ b/backend/src/lib/domain/warren/service/file_system.rs @@ -1,8 +1,9 @@ use crate::domain::warren::{ models::file::{ CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, - DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, - FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, + DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, + FetchFileError, FetchFileRequest, File, FilePath, FileStream, ListFilesError, + ListFilesRequest, RenameEntryError, RenameEntryRequest, }, ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService}, }; @@ -98,6 +99,20 @@ where result } + async fn fetch_file(&self, request: FetchFileRequest) -> Result { + 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 { let result = self.repository.delete_file(request).await; diff --git a/backend/src/lib/domain/warren/service/warren.rs b/backend/src/lib/domain/warren/service/warren.rs index 1ef5e8c..95d8491 100644 --- a/backend/src/lib/domain/warren/service/warren.rs +++ b/backend/src/lib/domain/warren/service/warren.rs @@ -1,11 +1,14 @@ use crate::domain::warren::{ - models::warren::{ - CreateWarrenDirectoryResponse, CreateWarrenError, CreateWarrenRequest, - DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileResponse, - DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrensError, - FetchWarrensRequest, ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, - RenameWarrenEntryError, RenameWarrenEntryRequest, RenameWarrenEntryResponse, - UploadWarrenFilesResponse, + models::{ + file::FileStream, + warren::{ + CreateWarrenDirectoryResponse, CreateWarrenError, CreateWarrenRequest, + DeleteWarrenDirectoryResponse, DeleteWarrenError, DeleteWarrenFileResponse, + DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenFileError, + FetchWarrenFileRequest, FetchWarrensError, FetchWarrensRequest, + ListWarrenFilesResponse, ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, + RenameWarrenEntryRequest, RenameWarrenEntryResponse, UploadWarrenFilesResponse, + }, }, ports::FileSystemService, }; @@ -146,6 +149,30 @@ where result } + async fn fetch_warren_file( + &self, + request: FetchWarrenFileRequest, + ) -> Result { + 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( &self, request: ListWarrenFilesRequest, diff --git a/backend/src/lib/inbound/http/handlers/warrens/fetch_file.rs b/backend/src/lib/inbound/http/handlers/warrens/fetch_file.rs new file mode 100644 index 0000000..54c3ccf --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/fetch_file.rs @@ -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> for FetchWarrenFileHttpResponseBody { + fn from(value: Vec) -> 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 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 { + let path = FilePath::new(&self.path)?.try_into()?; + + Ok(FetchWarrenFileRequest::new(self.warren_id, path)) + } +} + +impl From for Body { + fn from(value: FileStream) -> Self { + Body::from_stream::>(value.into()) + } +} + +pub async fn fetch_file( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Query(request): Query, +) -> Result { + 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) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/mod.rs b/backend/src/lib/inbound/http/handlers/warrens/mod.rs index 16b5c4f..27a8c11 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/mod.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -1,6 +1,7 @@ mod create_warren_directory; mod delete_warren_directory; mod delete_warren_file; +mod fetch_file; mod fetch_warren; mod list_warren_files; mod list_warrens; @@ -26,6 +27,7 @@ use create_warren_directory::create_warren_directory; use delete_warren_directory::delete_warren_directory; use delete_warren_file::delete_warren_file; +use fetch_file::fetch_file; use rename_warren_entry::rename_warren_entry; use upload_warren_files::upload_warren_files; @@ -33,6 +35,7 @@ pub fn routes() -> Router> Router::new() .route("/", get(list_warrens)) .route("/", post(fetch_warren)) + .route("/files/fetch", get(fetch_file)) .route("/files", post(list_warren_files)) .route("/files/directory", post(create_warren_directory)) .route("/files/directory", delete(delete_warren_directory)) diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index 41eb5ff..b1f6a9f 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -1,40 +1,65 @@ +use std::os::unix::fs::MetadataExt; + use anyhow::{Context, anyhow, bail}; use rustix::fs::statx; use tokio::{ fs, io::{self, AsyncWriteExt as _}, }; +use tokio_util::io::ReaderStream; -use crate::domain::warren::{ - models::file::{ - AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, - CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, - DeleteFileRequest, File, FileMimeType, FileName, FilePath, FileType, ListFilesError, - ListFilesRequest, RenameEntryError, RenameEntryRequest, +use crate::{ + config::Config, + domain::warren::{ + models::file::{ + AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, + 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)] pub struct FileSystemConfig { base_directory: String, + max_file_fetch_bytes: u64, } impl FileSystemConfig { - pub fn new(base_directory: String) -> Self { - Self { base_directory } + pub fn new(base_directory: String, max_file_fetch_bytes: u64) -> Self { + Self { + base_directory, + max_file_fetch_bytes, + } + } + + pub fn from_env(serve_dir: String) -> anyhow::Result { + // 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)] pub struct FileSystem { base_directory: FilePath, + max_file_fetch_bytes: u64, } impl FileSystem { pub fn new(config: FileSystemConfig) -> anyhow::Result { let file_system = Self { base_directory: FilePath::new(&config.base_directory)?, + max_file_fetch_bytes: config.max_file_fetch_bytes, }; Ok(file_system) @@ -146,6 +171,27 @@ impl FileSystem { Ok(path) } + async fn fetch_file(&self, path: &AbsoluteFilePath) -> anyhow::Result { + 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 /// /// * `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) } + async fn fetch_file(&self, request: FetchFileRequest) -> Result { + 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 { let deleted_path = self .remove_file(request.path()) diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index 7ed7c17..1fc1304 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -52,6 +52,13 @@ impl WarrenMetrics for MetricsDebugLogger { 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) { tracing::debug!("[Metrics] Warren list files succeeded"); } @@ -134,6 +141,13 @@ impl FileSystemMetrics for MetricsDebugLogger { 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) { tracing::debug!("[Metrics] File deletion succeeded"); } @@ -255,6 +269,13 @@ impl AuthMetrics for MetricsDebugLogger { 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) { tracing::debug!("[Metrics] Auth warren list succeeded"); } diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 2e84ed2..096214d 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::domain::warren::{ models::{ auth_session::requests::FetchAuthSessionResponse, - file::{File, FilePath}, + file::{AbsoluteFilePath, File, FilePath}, user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ @@ -46,6 +46,14 @@ impl WarrenNotifier for NotifierDebugLogger { 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) { tracing::debug!( "[Notifier] Listed {} file(s) in warren {}", @@ -121,6 +129,10 @@ impl FileSystemNotifier for NotifierDebugLogger { 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) { 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) { tracing::debug!( "[Notifier] Listed {} file(s) in warren {} for authenticated user {}", diff --git a/frontend/components/DirectoryEntry.vue b/frontend/components/DirectoryEntry.vue index e83a1ed..f222001 100644 --- a/frontend/components/DirectoryEntry.vue +++ b/frontend/components/DirectoryEntry.vue @@ -6,7 +6,11 @@ import { ContextMenuItem, ContextMenuSeparator, } 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'; const warrenStore = useWarrenStore(); @@ -49,11 +53,30 @@ async function openRenameDialog() { } async function onClick() { - if (warrenStore.loading) { + if (warrenStore.loading || warrenStore.current == null) { 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; + } + } } @@ -62,12 +85,7 @@ async function onClick() {