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",
"axum",
"axum_typed_multipart",
"base64",
"chrono",
"derive_more",
"dotenv",
@@ -2530,6 +2531,7 @@ dependencies = [
"sqlx",
"thiserror",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",

View File

@@ -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"

View File

@@ -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);

View File

@@ -1,5 +1,6 @@
mod requests;
pub use requests::*;
use tokio_util::io::ReaderStream;
use std::path::Path;
@@ -227,3 +228,22 @@ impl From<AbsoluteFilePath> for FilePath {
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)]
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::{
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<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_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_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_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_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_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
fn record_auth_warren_files_upload_success(&self) -> impl Future<Output = ()> + Send;
/// An upload failed at least partially

View File

@@ -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<Output = Result<Warren, FetchWarrenError>> + Send;
fn fetch_warren_file(
&self,
request: FetchWarrenFileRequest,
) -> impl Future<Output = Result<FileStream, FetchWarrenFileError>> + Send;
fn list_warren_files(
&self,
request: ListWarrenFilesRequest,
@@ -122,6 +129,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
&self,
request: CreateFileRequest,
) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send;
fn fetch_file(
&self,
request: FetchFileRequest,
) -> impl Future<Output = Result<FileStream, FetchFileError>> + Send;
fn delete_file(
&self,
request: DeleteFileRequest,
@@ -234,6 +245,12 @@ pub trait AuthService: Clone + Send + Sync + 'static {
warren_service: &WS,
) -> 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>(
&self,
request: AuthRequest<ListWarrenFilesRequest>,

View File

@@ -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<Output = ()> + Send;
fn warren_file_fetched(
&self,
warren: &Warren,
path: &AbsoluteFilePath,
) -> impl Future<Output = ()> + 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<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 entry_renamed(
@@ -153,6 +160,14 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
user: &User,
response: &DeleteWarrenDirectoryResponse,
) -> 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
///
/// * `warren`: The warren the file was uploaded to

View File

@@ -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<Output = Result<FilePath, CreateFileError>> + Send;
fn fetch_file(
&self,
request: FetchFileRequest,
) -> impl Future<Output = Result<FileStream, FetchFileError>> + Send;
fn delete_file(
&self,
request: DeleteFileRequest,

View File

@@ -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<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>(
&self,
request: AuthRequest<ListWarrenFilesRequest>,

View File

@@ -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<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> {
let result = self.repository.delete_file(request).await;

View File

@@ -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<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(
&self,
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 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<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
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))

View File

@@ -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<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)]
pub struct FileSystem {
base_directory: FilePath,
max_file_fetch_bytes: u64,
}
impl FileSystem {
pub fn new(config: FileSystemConfig) -> anyhow::Result<Self> {
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<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
///
/// * `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<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> {
let deleted_path = self
.remove_file(request.path())

View File

@@ -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");
}

View File

@@ -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 {}",

View File

@@ -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;
}
}
}
</script>
@@ -62,12 +85,7 @@ async function onClick() {
<ContextMenuTrigger>
<button
:disabled="warrenStore.loading || disabled"
:class="[
'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',
},
]"
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"
@click="onClick"
>
<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>
<SidebarProvider>
<ImageViewer />
<AppSidebar />
<SidebarInset class="flex flex-col-reverse md:flex-col">
<header

View File

@@ -265,3 +265,35 @@ export async function renameWarrenEntry(
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', {
state: () => ({
warrens: {} as Record<string, WarrenData>,
imageViewer: {
src: null as string | null,
},
current: null as { warrenId: string; path: string } | null,
loading: false,
}),