copy files

This commit is contained in:
2025-07-30 23:35:30 +02:00
parent 956c0c6f65
commit ea09b9c470
21 changed files with 552 additions and 46 deletions

View File

@@ -0,0 +1,57 @@
use thiserror::Error;
use crate::domain::warren::models::file::AbsoluteFilePath;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CpRequest {
path: AbsoluteFilePath,
target_path: AbsoluteFilePath,
}
impl CpRequest {
pub fn new(path: AbsoluteFilePath, target_path: AbsoluteFilePath) -> Self {
Self { path, target_path }
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
}
pub fn target_path(&self) -> &AbsoluteFilePath {
&self.target_path
}
pub fn into_paths(self) -> (AbsoluteFilePath, AbsoluteFilePath) {
(self.path, self.target_path)
}
}
#[derive(Debug, Error)]
pub enum CpError {
#[error("The file does not exist")]
NotFound,
#[error("The target path already exists")]
AlreadyExists,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CpResponse {
path: AbsoluteFilePath,
target_path: AbsoluteFilePath,
}
impl CpResponse {
pub fn new(path: AbsoluteFilePath, target_path: AbsoluteFilePath) -> Self {
Self { path, target_path }
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
}
pub fn target_path(&self) -> &AbsoluteFilePath {
&self.target_path
}
}

View File

@@ -1,4 +1,5 @@
mod cat;
mod cp;
mod ls;
mod mkdir;
mod mv;
@@ -7,6 +8,7 @@ mod save;
mod touch;
pub use cat::*;
pub use cp::*;
pub use ls::*;
pub use mkdir::*;
pub use mv::*;

View File

@@ -4,6 +4,9 @@ use futures_util::StreamExt;
use thiserror::Error;
use uuid::Uuid;
use crate::domain::warren::models::file::CpError;
use crate::domain::warren::models::file::CpRequest;
use crate::domain::warren::models::file::CpResponse;
use crate::domain::warren::models::file::LsResponse;
use crate::domain::warren::models::file::SaveResponse;
use crate::domain::warren::models::file::{
@@ -673,3 +676,68 @@ impl WarrenTouchResponse {
&self.path
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WarrenCpRequest {
warren_id: Uuid,
base: CpRequest,
}
impl WarrenCpRequest {
pub fn new(warren_id: Uuid, base: CpRequest) -> Self {
Self { warren_id, base }
}
pub fn warren_id(&self) -> &Uuid {
&self.warren_id
}
pub fn base(&self) -> &CpRequest {
&self.base
}
pub fn build_fs_request(self, warren: &Warren) -> CpRequest {
let (base_path, base_target_path) = self.base.into_paths();
let path = warren.path().clone().join(&base_path.to_relative());
let target_path = warren.path().clone().join(&base_target_path.to_relative());
CpRequest::new(path, target_path)
}
}
impl Into<FetchWarrenRequest> for &WarrenCpRequest {
fn into(self) -> FetchWarrenRequest {
FetchWarrenRequest::new(self.warren_id)
}
}
#[derive(Debug, Error)]
pub enum WarrenCpError {
#[error(transparent)]
FetchWarren(#[from] FetchWarrenError),
#[error(transparent)]
FileSystem(#[from] CpError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WarrenCpResponse {
warren: Warren,
base: CpResponse,
}
impl WarrenCpResponse {
pub fn new(warren: Warren, base: CpResponse) -> Self {
Self { warren, base }
}
pub fn warren(&self) -> &Warren {
&self.warren
}
pub fn base(&self) -> &CpResponse {
&self.base
}
}

View File

@@ -39,6 +39,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static {
fn record_warren_touch_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_touch_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_cp_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_cp_failure(&self) -> impl Future<Output = ()> + Send;
}
pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
@@ -62,6 +65,9 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
fn record_touch_success(&self) -> impl Future<Output = ()> + Send;
fn record_touch_failure(&self) -> impl Future<Output = ()> + Send;
fn record_cp_success(&self) -> impl Future<Output = ()> + Send;
fn record_cp_failure(&self) -> impl Future<Output = ()> + Send;
}
pub trait AuthMetrics: Clone + Send + Sync + 'static {
@@ -139,4 +145,7 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static {
fn record_auth_warren_touch_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_touch_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_cp_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_warren_cp_failure(&self) -> impl Future<Output = ()> + Send;
}

View File

@@ -15,9 +15,9 @@ use super::models::{
},
},
file::{
CatError, CatRequest, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest,
MvError, MvRequest, RmError, RmRequest, SaveError, SaveRequest, SaveResponse, TouchError,
TouchRequest,
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
SaveRequest, SaveResponse, TouchError, TouchRequest,
},
user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
@@ -37,11 +37,11 @@ use super::models::{
CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest,
EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest,
FetchWarrensError, FetchWarrensRequest, ListWarrensError, ListWarrensRequest, Warren,
WarrenCatError, WarrenCatRequest, WarrenLsError, WarrenLsRequest, WarrenLsResponse,
WarrenMkdirError, WarrenMkdirRequest, WarrenMkdirResponse, WarrenMvError, WarrenMvRequest,
WarrenMvResponse, WarrenRmError, WarrenRmRequest, WarrenRmResponse, WarrenSaveError,
WarrenSaveRequest, WarrenSaveResponse, WarrenTouchError, WarrenTouchRequest,
WarrenTouchResponse,
WarrenCatError, WarrenCatRequest, WarrenCpError, WarrenCpRequest, WarrenCpResponse,
WarrenLsError, WarrenLsRequest, WarrenLsResponse, WarrenMkdirError, WarrenMkdirRequest,
WarrenMkdirResponse, WarrenMvError, WarrenMvRequest, WarrenMvResponse, WarrenRmError,
WarrenRmRequest, WarrenRmResponse, WarrenSaveError, WarrenSaveRequest, WarrenSaveResponse,
WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse,
},
};
@@ -100,6 +100,10 @@ pub trait WarrenService: Clone + Send + Sync + 'static {
&self,
request: WarrenTouchRequest,
) -> impl Future<Output = Result<WarrenTouchResponse, WarrenTouchError>> + Send;
fn warren_cp(
&self,
request: WarrenCpRequest,
) -> impl Future<Output = Result<WarrenCpResponse, WarrenCpError>> + Send;
}
pub trait FileSystemService: Clone + Send + Sync + 'static {
@@ -114,6 +118,7 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
request: SaveRequest,
) -> impl Future<Output = Result<SaveResponse, SaveError>> + Send;
fn touch(&self, request: TouchRequest) -> impl Future<Output = Result<(), TouchError>> + Send;
fn cp(&self, request: CpRequest) -> impl Future<Output = Result<CpResponse, CpError>> + Send;
}
pub trait AuthService: Clone + Send + Sync + 'static {
@@ -252,4 +257,9 @@ pub trait AuthService: Clone + Send + Sync + 'static {
request: AuthRequest<WarrenTouchRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<WarrenTouchResponse, AuthError<WarrenTouchError>>> + Send;
fn auth_warren_cp<WS: WarrenService>(
&self,
request: AuthRequest<WarrenCpRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<WarrenCpResponse, AuthError<WarrenCpError>>> + Send;
}

View File

@@ -6,8 +6,8 @@ use crate::domain::warren::models::{
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
user_warren::UserWarren,
warren::{
Warren, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse, WarrenRmResponse,
WarrenSaveResponse, WarrenTouchResponse,
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
},
};
@@ -43,6 +43,7 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static {
warren: &Warren,
path: &AbsoluteFilePath,
) -> impl Future<Output = ()> + Send;
fn warren_cp(&self, response: &WarrenCpResponse) -> impl Future<Output = ()> + Send;
}
pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
@@ -57,6 +58,11 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
) -> impl Future<Output = ()> + Send;
fn save(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
fn touch(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
fn cp(
&self,
path: &AbsoluteFilePath,
target_path: &AbsoluteFilePath,
) -> impl Future<Output = ()> + Send;
}
pub trait AuthNotifier: Clone + Send + Sync + 'static {
@@ -160,4 +166,9 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
user: &User,
response: &WarrenTouchResponse,
) -> impl Future<Output = ()> + Send;
fn auth_warren_cp(
&self,
user: &User,
response: &WarrenCpResponse,
) -> impl Future<Output = ()> + Send;
}

View File

@@ -7,9 +7,9 @@ use crate::domain::warren::models::{
},
},
file::{
CatError, CatRequest, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest,
MvError, MvRequest, RmError, RmRequest, SaveError, SaveRequest, SaveResponse, TouchError,
TouchRequest,
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
SaveRequest, SaveResponse, TouchError, TouchRequest,
},
user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
@@ -77,6 +77,7 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
request: SaveRequest,
) -> impl Future<Output = Result<SaveResponse, SaveError>> + Send;
fn touch(&self, request: TouchRequest) -> impl Future<Output = Result<(), TouchError>> + Send;
fn cp(&self, request: CpRequest) -> impl Future<Output = Result<CpResponse, CpError>> + Send;
}
pub trait AuthRepository: Clone + Send + Sync + 'static {

View File

@@ -28,12 +28,12 @@ use crate::{
warren::{
CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest,
EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest,
FetchWarrensRequest, Warren, WarrenCatError, WarrenCatRequest, WarrenLsError,
WarrenLsRequest, WarrenLsResponse, WarrenMkdirError, WarrenMkdirRequest,
WarrenMkdirResponse, WarrenMvError, WarrenMvRequest, WarrenMvResponse,
WarrenRmError, WarrenRmRequest, WarrenRmResponse, WarrenSaveError,
WarrenSaveRequest, WarrenSaveResponse, WarrenTouchError, WarrenTouchRequest,
WarrenTouchResponse,
FetchWarrensRequest, Warren, WarrenCatError, WarrenCatRequest, WarrenCpError,
WarrenCpRequest, WarrenCpResponse, WarrenLsError, WarrenLsRequest,
WarrenLsResponse, WarrenMkdirError, WarrenMkdirRequest, WarrenMkdirResponse,
WarrenMvError, WarrenMvRequest, WarrenMvResponse, WarrenRmError, WarrenRmRequest,
WarrenRmResponse, WarrenSaveError, WarrenSaveRequest, WarrenSaveResponse,
WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse,
},
},
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService},
@@ -839,12 +839,51 @@ where
.map_err(AuthError::Custom);
if let Ok(response) = result.as_ref() {
self.metrics.record_auth_warren_save_success().await;
self.metrics.record_auth_warren_touch_success().await;
self.notifier
.auth_warren_touch(session_response.user(), response)
.await;
} else {
self.metrics.record_auth_warren_save_failure().await;
self.metrics.record_auth_warren_touch_failure().await;
}
result
}
async fn auth_warren_cp<WS: WarrenService>(
&self,
request: AuthRequest<WarrenCpRequest>,
warren_service: &WS,
) -> Result<WarrenCpResponse, AuthError<WarrenCpError>> {
let session_response = self.fetch_auth_session((&request).into()).await?;
let request = request.into_value();
let user_warren = self
.repository
.fetch_user_warren(FetchUserWarrenRequest::new(
session_response.user().id().clone(),
request.warren_id().clone(),
))
.await?;
// TODO: Maybe create a separate permission for this
if !user_warren.can_modify_files() {
return Err(AuthError::InsufficientPermissions);
}
let result = warren_service
.warren_cp(request)
.await
.map_err(AuthError::Custom);
if let Ok(response) = result.as_ref() {
self.metrics.record_auth_warren_cp_success().await;
self.notifier
.auth_warren_cp(session_response.user(), response)
.await;
} else {
self.metrics.record_auth_warren_cp_failure().await;
}
result

View File

@@ -1,8 +1,8 @@
use crate::domain::warren::{
models::file::{
CatError, CatRequest, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest,
MvError, MvRequest, RmError, RmRequest, SaveError, SaveRequest, SaveResponse, TouchError,
TouchRequest,
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
SaveRequest, SaveResponse, TouchError, TouchRequest,
},
ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService},
};
@@ -137,4 +137,19 @@ where
result
}
async fn cp(&self, request: CpRequest) -> Result<CpResponse, CpError> {
let path = request.path().clone();
let target_path = request.target_path().clone();
let result = self.repository.cp(request).await;
if result.is_ok() {
self.metrics.record_cp_success().await;
self.notifier.cp(&path, &target_path).await;
} else {
self.metrics.record_cp_failure().await;
}
result
}
}

View File

@@ -4,10 +4,10 @@ use crate::domain::warren::{
warren::{
CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest,
EditWarrenError, EditWarrenRequest, FetchWarrensError, FetchWarrensRequest,
ListWarrensError, ListWarrensRequest, WarrenCatError, WarrenCatRequest,
WarrenLsResponse, WarrenMkdirResponse, WarrenMvError, WarrenMvRequest,
WarrenMvResponse, WarrenRmRequest, WarrenRmResponse, WarrenSaveResponse,
WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse,
ListWarrensError, ListWarrensRequest, WarrenCatError, WarrenCatRequest, WarrenCpError,
WarrenCpRequest, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse,
WarrenMvError, WarrenMvRequest, WarrenMvResponse, WarrenRmRequest, WarrenRmResponse,
WarrenSaveResponse, WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse,
},
},
ports::FileSystemService,
@@ -314,4 +314,25 @@ where
result
}
async fn warren_cp(&self, request: WarrenCpRequest) -> Result<WarrenCpResponse, WarrenCpError> {
let warren = self.repository.fetch_warren((&request).into()).await?;
let cp_request = request.build_fs_request(&warren);
let result = self
.fs_service
.cp(cp_request)
.await
.map(|base| WarrenCpResponse::new(warren, base))
.map_err(Into::into);
if let Ok(response) = result.as_ref() {
self.metrics.record_warren_cp_success().await;
self.notifier.warren_cp(response).await;
} else {
self.metrics.record_warren_cp_failure().await;
}
result
}
}

View File

@@ -2,9 +2,10 @@ mod fetch_warren;
mod list_warrens;
mod upload_warren_files;
mod warren_cat;
mod warren_cp;
mod warren_ls;
mod warren_mkdir;
mod warren_move;
mod warren_mv;
mod warren_rm;
use axum::{
@@ -27,7 +28,8 @@ use warren_rm::warren_rm;
use upload_warren_files::warren_save;
use warren_cat::fetch_file;
use warren_move::warren_move;
use warren_cp::warren_cp;
use warren_mv::warren_mv;
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new()
@@ -42,5 +44,6 @@ pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
// 10737418240 bytes = 10GB
post(warren_save).route_layer(DefaultBodyLimit::max(10737418240)),
)
.route("/files/mv", post(warren_move))
.route("/files/mv", post(warren_mv))
.route("/files/cp", post(warren_cp))
}

View File

@@ -0,0 +1,80 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Deserialize;
use thiserror::Error;
use uuid::Uuid;
use crate::{
domain::warren::{
models::{
auth_session::AuthRequest,
file::{AbsoluteFilePath, AbsoluteFilePathError, CpRequest, FilePath, FilePathError},
warren::WarrenCpRequest,
},
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
handlers::extractors::SessionIdHeader,
responses::{ApiError, ApiSuccess},
},
};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CpWarrenEntryHttpRequestBody {
warren_id: Uuid,
path: String,
target_path: String,
}
#[derive(Debug, Clone, Error)]
pub enum ParseWarrenCpHttpRequestError {
#[error(transparent)]
FilePath(#[from] FilePathError),
#[error(transparent)]
AbsoluteFilePath(#[from] AbsoluteFilePathError),
}
impl CpWarrenEntryHttpRequestBody {
fn try_into_domain(self) -> Result<WarrenCpRequest, ParseWarrenCpHttpRequestError> {
let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?;
let target_path: AbsoluteFilePath = FilePath::new(&self.target_path)?.try_into()?;
Ok(WarrenCpRequest::new(
self.warren_id,
CpRequest::new(path, target_path),
))
}
}
impl From<ParseWarrenCpHttpRequestError> for ApiError {
fn from(value: ParseWarrenCpHttpRequestError) -> Self {
match value {
ParseWarrenCpHttpRequestError::FilePath(err) => match err {
FilePathError::InvalidPath => {
ApiError::BadRequest("The file path must be valid".to_string())
}
},
ParseWarrenCpHttpRequestError::AbsoluteFilePath(err) => match err {
AbsoluteFilePathError::NotAbsolute => {
ApiError::BadRequest("The file path must be absolute".to_string())
}
},
}
}
}
pub async fn warren_cp<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Json(request): Json<CpWarrenEntryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = AuthRequest::new(session, request.try_into_domain()?);
state
.auth_service
.auth_warren_cp(domain_request, state.warren_service.as_ref())
.await
.map(|_| ApiSuccess::new(StatusCode::OK, ()))
.map_err(ApiError::from)
}

View File

@@ -21,7 +21,7 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameWarrenEntryHttpRequestBody {
pub struct MvWarrenEntryHttpRequestBody {
warren_id: Uuid,
path: String,
target_path: String,
@@ -35,7 +35,7 @@ pub enum ParseWarrenMvHttpRequestError {
AbsoluteFilePath(#[from] AbsoluteFilePathError),
}
impl RenameWarrenEntryHttpRequestBody {
impl MvWarrenEntryHttpRequestBody {
fn try_into_domain(self) -> Result<WarrenMvRequest, ParseWarrenMvHttpRequestError> {
let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?;
let target_path: AbsoluteFilePath = FilePath::new(&self.target_path)?.try_into()?;
@@ -64,10 +64,10 @@ impl From<ParseWarrenMvHttpRequestError> for ApiError {
}
}
pub async fn warren_move<WS: WarrenService, AS: AuthService>(
pub async fn warren_mv<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Json(request): Json<RenameWarrenEntryHttpRequestBody>,
Json(request): Json<MvWarrenEntryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = AuthRequest::new(session, request.try_into_domain()?);

View File

@@ -14,10 +14,10 @@ use crate::{
domain::warren::{
models::{
file::{
AbsoluteFilePath, CatError, CatRequest, File, FileMimeType, FileName, FilePath,
FileStream, FileType, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest,
MvError, MvRequest, RelativeFilePath, RmError, RmRequest, SaveError, SaveRequest,
SaveResponse, TouchError, TouchRequest,
AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, File,
FileMimeType, FileName, FilePath, FileStream, FileType, LsError, LsRequest,
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RelativeFilePath,
RmError, RmRequest, SaveError, SaveRequest, SaveResponse, TouchError, TouchRequest,
},
warren::UploadFileStream,
},
@@ -242,7 +242,20 @@ impl FileSystem {
async fn touch(&self, path: &AbsoluteFilePath) -> io::Result<()> {
let path = self.get_target_path(path);
tokio::fs::File::create(&path).await.map(|_| ())
fs::File::create(&path).await.map(|_| ())
}
async fn cp(
&self,
path: AbsoluteFilePath,
target_path: AbsoluteFilePath,
) -> io::Result<CpResponse> {
let fs_current_path = self.get_target_path(&path);
let fs_target_path = self.get_target_path(&target_path);
fs::copy(fs_current_path, fs_target_path).await?;
Ok(CpResponse::new(path, target_path))
}
}
@@ -313,6 +326,13 @@ impl FileSystemRepository for FileSystem {
let (path, mut stream) = request.unpack();
Ok(self.save(&path, &mut stream).await.map(SaveResponse::new)?)
}
async fn cp(&self, request: CpRequest) -> Result<CpResponse, CpError> {
let (path, target_path) = request.into_paths();
self.cp(path, target_path)
.await
.map_err(|e| CpError::Unknown(e.into()))
}
}
// TODO: Use `DirEntry::metadata` once `target=x86_64-unknown-linux-musl` updates from musl 1.2.3 to 1.2.5

View File

@@ -100,6 +100,13 @@ impl WarrenMetrics for MetricsDebugLogger {
async fn record_warren_touch_failure(&self) {
tracing::debug!("[Metrics] Warren entry touch failed");
}
async fn record_warren_cp_success(&self) {
tracing::debug!("[Metrics] Warren entry cp succeeded");
}
async fn record_warren_cp_failure(&self) {
tracing::debug!("[Metrics] Warren entry cp failed");
}
}
impl FileSystemMetrics for MetricsDebugLogger {
@@ -151,6 +158,13 @@ impl FileSystemMetrics for MetricsDebugLogger {
async fn record_touch_failure(&self) {
tracing::debug!("[Metrics] Touch failed");
}
async fn record_cp_success(&self) {
tracing::debug!("[Metrics] Cp succeeded");
}
async fn record_cp_failure(&self) {
tracing::debug!("[Metrics] Cp failed");
}
}
impl AuthMetrics for MetricsDebugLogger {
@@ -328,4 +342,11 @@ impl AuthMetrics for MetricsDebugLogger {
async fn record_auth_warren_touch_failure(&self) {
tracing::debug!("[Metrics] Auth warren touch failed");
}
async fn record_auth_warren_cp_success(&self) {
tracing::debug!("[Metrics] Auth warren cp succeeded");
}
async fn record_auth_warren_cp_failure(&self) {
tracing::debug!("[Metrics] Auth warren cp failed");
}
}

View File

@@ -7,8 +7,8 @@ use crate::domain::warren::{
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
user_warren::UserWarren,
warren::{
Warren, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse, WarrenRmResponse,
WarrenSaveResponse, WarrenTouchResponse,
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
},
},
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
@@ -98,6 +98,15 @@ impl WarrenNotifier for NotifierDebugLogger {
warren.name()
);
}
async fn warren_cp(&self, response: &WarrenCpResponse) {
tracing::debug!(
"[Notifier] Copied file {} to {} in warren {}",
response.base().path(),
response.base().target_path(),
response.warren().name()
);
}
}
impl FileSystemNotifier for NotifierDebugLogger {
@@ -128,6 +137,10 @@ impl FileSystemNotifier for NotifierDebugLogger {
async fn touch(&self, path: &AbsoluteFilePath) {
tracing::debug!("[Notifier] Touched file {}", path);
}
async fn cp(&self, path: &AbsoluteFilePath, target_path: &AbsoluteFilePath) {
tracing::debug!("[Notifier] Copied file {} to {}", path, target_path);
}
}
impl AuthNotifier for NotifierDebugLogger {
@@ -330,4 +343,14 @@ impl AuthNotifier for NotifierDebugLogger {
user.id()
)
}
async fn auth_warren_cp(&self, user: &User, response: &WarrenCpResponse) {
tracing::debug!(
"[Notifier] Copied file {} to {} in warren {} for authenticated user {}",
response.base().path(),
response.base().target_path(),
response.warren().name(),
user.id()
)
}
}

View File

@@ -14,6 +14,7 @@ import {
import type { DirectoryEntry } from '#shared/types';
const warrenStore = useWarrenStore();
const copyStore = useCopyStore();
const renameDialog = useRenameDirectoryDialog();
const { entry, disabled } = defineProps<{
@@ -22,6 +23,14 @@ const { entry, disabled } = defineProps<{
}>();
const deleting = ref(false);
const isCopied = computed(
() =>
warrenStore.current != null &&
copyStore.file != null &&
warrenStore.current.warrenId === copyStore.file.warrenId &&
warrenStore.current.path === copyStore.file.path &&
entry.name === copyStore.file.name
);
async function submitDelete(force: boolean = false) {
if (warrenStore.current == null) {
@@ -89,6 +98,18 @@ function onDragStart(e: DragEvent) {
}
const onDrop = onDirectoryEntryDrop(entry);
function onCopy() {
if (warrenStore.current == null) {
return;
}
copyStore.copyFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
}
</script>
<template>
@@ -96,7 +117,10 @@ const onDrop = onDirectoryEntryDrop(entry);
<ContextMenuTrigger>
<button
:disabled="warrenStore.loading || disabled"
class="bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none"
:class="[
'bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
isCopied && 'border-primary/50 border',
]"
draggable="true"
@dragstart="onDragStart"
@drop="onDrop"
@@ -124,6 +148,10 @@ const onDrop = onDirectoryEntryDrop(entry);
<Icon name="lucide:pencil" />
Rename
</ContextMenuItem>
<ContextMenuItem @select="onCopy">
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
<ContextMenuSeparator />

View File

@@ -6,11 +6,35 @@ import {
ContextMenuItem,
} from '@/components/ui/context-menu';
const dialog = useCreateDirectoryDialog();
const warrenStore = useWarrenStore();
const copyStore = useCopyStore();
const createDirectoryDialog = useCreateDirectoryDialog();
const pasting = ref<boolean>(false);
const validPaste = computed(
() =>
!pasting.value && copyStore.file != null && warrenStore.current != null
);
const props = defineProps<{
class?: string;
}>();
async function onPaste() {
if (!validPaste.value) {
return;
}
pasting.value = true;
await pasteFile(copyStore.file!, {
warrenId: warrenStore.current!.warrenId,
name: copyStore.file!.name,
path: warrenStore.current!.path,
});
pasting.value = false;
}
</script>
<template>
@@ -19,7 +43,11 @@ const props = defineProps<{
<slot />
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem @select="dialog.openDialog">
<ContextMenuItem @disabled="!validPaste" @select="onPaste">
<Icon name="lucide:clipboard-paste" />
Paste
</ContextMenuItem>
<ContextMenuItem @select="createDirectoryDialog.openDialog">
<Icon name="lucide:folder-plus" />
Create directory
</ContextMenuItem>

View File

@@ -335,3 +335,38 @@ export async function moveFile(
success: true,
};
}
export async function copyFile(
warrenId: string,
currentPath: string,
targetPath: string
): Promise<{ success: boolean }> {
const { status } = await useFetch(getApiUrl(`warrens/files/cp`), {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId,
path: currentPath,
targetPath: targetPath,
}),
});
if (status.value !== 'success') {
toast.error('Copy', {
id: 'COPY_FILE_TOAST',
description: `Failed to copy file`,
});
return { success: false };
}
await refreshNuxtData('current-directory');
toast.success('Copy', {
id: 'COPY_FILE_TOAST',
description: `Successfully copied file`,
});
return {
success: true,
};
}

18
frontend/stores/copy.ts Normal file
View File

@@ -0,0 +1,18 @@
export const useCopyStore = defineStore('file-copy', {
state: () => ({
file: null as { warrenId: string; path: string; name: string } | null,
}),
actions: {
copyFile(warrenId: string, filePath: string, fileName: string) {
this.file = {
warrenId,
path: filePath,
name: fileName,
};
},
/** Removes the current file from the "clipboard" */
clearFile() {
this.file = null;
},
},
});

View File

@@ -1,4 +1,4 @@
import { moveFile } from '~/lib/api/warrens';
import { copyFile, moveFile } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types';
export function joinPaths(path: string, ...other: string[]): string {
@@ -58,3 +58,20 @@ export function onDirectoryEntryDrop(
await moveFile(warrenStore.current.warrenId, currentPath, targetPath);
};
}
export async function pasteFile(
current: { warrenId: string; path: string; name: string },
target: { warrenId: string; path: string; name: string }
): Promise<boolean> {
if (current.warrenId !== target.warrenId) {
throw new Error('Cross-warren copies are not supported yet');
}
const { success } = await copyFile(
current.warrenId,
joinPaths(current.path, current.name),
joinPaths(target.path, target.name)
);
return success;
}