diff --git a/backend/migrations/20250810215614_create_shares_table.sql b/backend/migrations/20250810215614_create_shares_table.sql new file mode 100644 index 0000000..c6f3c15 --- /dev/null +++ b/backend/migrations/20250810215614_create_shares_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE shares ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + warren_id UUID NOT NULL REFERENCES warrens(id) ON DELETE CASCADE, + path VARCHAR NOT NULL, + password_hash VARCHAR NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/migrations/20250825115342_share_permissions.sql b/backend/migrations/20250825115342_share_permissions.sql new file mode 100644 index 0000000..985d10a --- /dev/null +++ b/backend/migrations/20250825115342_share_permissions.sql @@ -0,0 +1,21 @@ +ALTER TABLE + user_warrens +ADD COLUMN + can_list_shares BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN + can_create_shares BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN + can_modify_shares BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN + can_delete_shares BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE + user_warrens +ALTER COLUMN + can_list_shares DROP DEFAULT, +ALTER COLUMN + can_create_shares DROP DEFAULT, +ALTER COLUMN + can_modify_shares DROP DEFAULT, +ALTER COLUMN + can_delete_shares DROP DEFAULT; diff --git a/backend/migrations/20250825143434_shares_password_nullable.sql b/backend/migrations/20250825143434_shares_password_nullable.sql new file mode 100644 index 0000000..6b9e082 --- /dev/null +++ b/backend/migrations/20250825143434_shares_password_nullable.sql @@ -0,0 +1 @@ +ALTER TABLE shares ALTER COLUMN password_hash DROP NOT NULL; diff --git a/backend/migrations/20250825150026_shares_path_index.sql b/backend/migrations/20250825150026_shares_path_index.sql new file mode 100644 index 0000000..58e1ed8 --- /dev/null +++ b/backend/migrations/20250825150026_shares_path_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_shares_path ON shares(path); diff --git a/backend/src/lib/domain/warren/models/auth_session/requests.rs b/backend/src/lib/domain/warren/models/auth_session/requests.rs index ab286d4..6521e56 100644 --- a/backend/src/lib/domain/warren/models/auth_session/requests.rs +++ b/backend/src/lib/domain/warren/models/auth_session/requests.rs @@ -69,6 +69,10 @@ impl FetchAuthSessionResponse { pub fn user(&self) -> &User { &self.user } + + pub fn unpack(self) -> (AuthSession, User) { + (self.session, self.user) + } } impl FetchAuthSessionRequest { diff --git a/backend/src/lib/domain/warren/models/file/mod.rs b/backend/src/lib/domain/warren/models/file/mod.rs index 148b97f..1321971 100644 --- a/backend/src/lib/domain/warren/models/file/mod.rs +++ b/backend/src/lib/domain/warren/models/file/mod.rs @@ -142,6 +142,10 @@ impl FilePath { self.0.push('/'); self.0.push_str(&other.0); } + + pub fn as_str(&self) -> &str { + &self.0 + } } impl AsRef for FilePath { @@ -172,6 +176,10 @@ impl RelativeFilePath { self.0.push_str(&other.0); self } + + pub fn as_str(&self) -> &str { + &self.0 + } } impl AbsoluteFilePath { @@ -199,6 +207,10 @@ impl AbsoluteFilePath { self } + + pub fn as_str(&self) -> &str { + &self.0 + } } #[derive(Clone, Debug, Error)] diff --git a/backend/src/lib/domain/warren/models/file/requests/mod.rs b/backend/src/lib/domain/warren/models/file/requests/mod.rs index 9aed375..5029898 100644 --- a/backend/src/lib/domain/warren/models/file/requests/mod.rs +++ b/backend/src/lib/domain/warren/models/file/requests/mod.rs @@ -5,6 +5,7 @@ mod mkdir; mod mv; mod rm; mod save; +mod stat; mod touch; pub use cat::*; @@ -14,4 +15,5 @@ pub use mkdir::*; pub use mv::*; pub use rm::*; pub use save::*; +pub use stat::*; pub use touch::*; diff --git a/backend/src/lib/domain/warren/models/file/requests/stat.rs b/backend/src/lib/domain/warren/models/file/requests/stat.rs new file mode 100644 index 0000000..b08ae8f --- /dev/null +++ b/backend/src/lib/domain/warren/models/file/requests/stat.rs @@ -0,0 +1,51 @@ +use thiserror::Error; + +use crate::domain::warren::models::file::{AbsoluteFilePath, File}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct StatRequest { + path: AbsoluteFilePath, +} + +impl StatRequest { + pub fn new(path: AbsoluteFilePath) -> Self { + Self { path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn into_path(self) -> AbsoluteFilePath { + self.path + } +} + +#[derive(Debug, Error)] +pub enum StatError { + #[error("The file does not exist")] + NotFound, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct StatResponse { + file: File, +} + +impl StatResponse { + pub fn new(file: File) -> Self { + Self { file } + } + + pub fn file(&self) -> &File { + &self.file + } +} + +impl From for File { + fn from(value: StatResponse) -> Self { + value.file + } +} diff --git a/backend/src/lib/domain/warren/models/mod.rs b/backend/src/lib/domain/warren/models/mod.rs index 028de8b..05951b6 100644 --- a/backend/src/lib/domain/warren/models/mod.rs +++ b/backend/src/lib/domain/warren/models/mod.rs @@ -1,5 +1,6 @@ pub mod auth_session; pub mod file; +pub mod share; pub mod user; pub mod user_warren; pub mod warren; diff --git a/backend/src/lib/domain/warren/models/share/mod.rs b/backend/src/lib/domain/warren/models/share/mod.rs new file mode 100644 index 0000000..d43eb48 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/mod.rs @@ -0,0 +1,134 @@ +mod requests; +pub use requests::*; + +use thiserror::Error; +use uuid::Uuid; + +use super::{ + file::{AbsoluteFilePath, StatRequest}, + warren::Warren, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Share { + id: Uuid, + creator_id: Uuid, + + warren_id: Uuid, + path: AbsoluteFilePath, + + password_hash: Option, + + expires_at: Option, + + created_at: u64, +} + +impl Share { + pub fn new( + id: Uuid, + creator_id: Uuid, + warren_id: Uuid, + path: AbsoluteFilePath, + password_hash: Option, + expires_at: Option, + created_at: u64, + ) -> Self { + Self { + id, + creator_id, + warren_id, + path, + password_hash, + expires_at, + created_at, + } + } + + pub fn id(&self) -> &Uuid { + &self.id + } + + pub fn creator_id(&self) -> &Uuid { + &self.creator_id + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn password_hash(&self) -> Option<&String> { + self.password_hash.as_ref() + } + + pub fn expires_at(&self) -> Option { + self.expires_at + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn build_fs_stat_request(&self, warren: &Warren) -> StatRequest { + let base_path = self.path.clone(); + + let path = warren.path().clone().join(&base_path.to_relative()); + + StatRequest::new(path) + } +} + +/// A valid share password +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SharePassword(String); + +#[derive(Clone, Debug, Error)] +pub enum SharePasswordError { + #[error("A share's password must not be empty")] + Empty, + #[error("A share's password must not start with a whitespace")] + LeadingWhitespace, + #[error("A share's password must not end with a whitespace")] + TrailingWhitespace, + #[error("A share's password must be longer")] + TooShort, + #[error("A share's password must be shorter")] + TooLong, +} + +impl SharePassword { + const MIN_LENGTH: usize = 1; + const MAX_LENGTH: usize = 64; + + pub fn new(raw: &str) -> Result { + if raw.is_empty() { + return Err(SharePasswordError::Empty); + } + + if raw.trim_start().len() != raw.len() { + return Err(SharePasswordError::LeadingWhitespace); + } + + if raw.trim_end().len() != raw.len() { + return Err(SharePasswordError::TrailingWhitespace); + } + + if raw.len() < Self::MIN_LENGTH { + return Err(SharePasswordError::TooShort); + } + + if raw.len() > Self::MAX_LENGTH { + return Err(SharePasswordError::TooLong); + } + + Ok(Self(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/backend/src/lib/domain/warren/models/share/requests/cat.rs b/backend/src/lib/domain/warren/models/share/requests/cat.rs new file mode 100644 index 0000000..5e6a1ed --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/cat.rs @@ -0,0 +1,104 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{AbsoluteFilePath, CatRequest, FileStream}, + share::{Share, SharePassword}, + warren::{FetchWarrenError, Warren, WarrenCatError, WarrenCatRequest}, +}; + +use super::{VerifySharePasswordError, VerifySharePasswordRequest}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ShareCatRequest { + share_id: Uuid, + path: AbsoluteFilePath, + password: Option, +} + +impl ShareCatRequest { + pub fn new(share_id: Uuid, path: AbsoluteFilePath, password: Option) -> Self { + Self { + share_id, + path, + password, + } + } + + pub fn share_id(&self) -> &Uuid { + &self.share_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn password(&self) -> Option<&SharePassword> { + self.password.as_ref() + } + + pub fn build_warren_cat_request(self, share: &Share, warren: &Warren) -> WarrenCatRequest { + let path = share.path().clone().join(&self.path.to_relative()); + + WarrenCatRequest::new(*warren.id(), CatRequest::new(path)) + } +} + +impl From<&ShareCatRequest> for VerifySharePasswordRequest { + fn from(value: &ShareCatRequest) -> Self { + Self::new(value.share_id, value.password.clone()) + } +} + +#[derive(Debug)] +pub struct ShareCatResponse { + share: Share, + warren: Warren, + path: AbsoluteFilePath, + stream: FileStream, +} + +impl ShareCatResponse { + pub fn new(share: Share, warren: Warren, path: AbsoluteFilePath, stream: FileStream) -> Self { + Self { + share, + warren, + path, + stream, + } + } + + pub fn share(&self) -> &Share { + &self.share + } + + pub fn warren(&self) -> &Warren { + &self.warren + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn stream(&self) -> &FileStream { + &self.stream + } +} + +impl From for FileStream { + fn from(value: ShareCatResponse) -> Self { + value.stream + } +} + +#[derive(Debug, Error)] +pub enum ShareCatError { + #[error(transparent)] + VerifySharePassword(#[from] VerifySharePasswordError), + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + WarrenCat(#[from] WarrenCatError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/create.rs b/backend/src/lib/domain/warren/models/share/requests/create.rs new file mode 100644 index 0000000..c8af1f6 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/create.rs @@ -0,0 +1,121 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{AbsoluteFilePath, StatError, StatRequest}, + share::{Share, SharePassword}, + warren::{FetchWarrenError, FetchWarrenRequest, HasWarrenId, Warren}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateShareRequest { + creator_id: Uuid, + base: CreateShareBaseRequest, +} + +impl CreateShareRequest { + pub fn new(creator_id: Uuid, base: CreateShareBaseRequest) -> Self { + Self { creator_id, base } + } + + pub fn creator_id(&self) -> &Uuid { + &self.creator_id + } + + pub fn base(&self) -> &CreateShareBaseRequest { + &self.base + } + + pub fn build_fs_stat_request(&self, warren: &Warren) -> StatRequest { + let base_path = self.base.path().clone(); + + let path = warren.path().clone().join(&base_path.to_relative()); + + StatRequest::new(path) + } +} + +impl HasWarrenId for CreateShareRequest { + fn warren_id(&self) -> &Uuid { + &self.base.warren_id() + } +} + +impl Into for &CreateShareRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.base.warren_id) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateShareBaseRequest { + warren_id: Uuid, + path: AbsoluteFilePath, + + password: Option, + + lifetime: Option, +} + +impl CreateShareBaseRequest { + pub fn new( + warren_id: Uuid, + path: AbsoluteFilePath, + password: Option, + lifetime: Option, + ) -> Self { + Self { + warren_id, + path, + password, + lifetime, + } + } + + pub fn build_share_request(self, creator_id: Uuid) -> CreateShareRequest { + CreateShareRequest::new(creator_id, self) + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn password(&self) -> Option<&SharePassword> { + self.password.as_ref() + } + + pub fn lifetime(&self) -> Option { + self.lifetime + } +} + +impl HasWarrenId for CreateShareBaseRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateShareResponse { + share: Share, +} + +impl CreateShareResponse { + pub fn new(share: Share) -> Self { + Self { share } + } + + pub fn share(&self) -> &Share { + &self.share + } +} + +#[derive(Debug, Error)] +pub enum CreateShareError { + #[error(transparent)] + Stat(#[from] StatError), + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/delete.rs b/backend/src/lib/domain/warren/models/share/requests/delete.rs new file mode 100644 index 0000000..8d67489 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/delete.rs @@ -0,0 +1,61 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + share::Share, + warren::{FetchWarrenError, HasWarrenId}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteShareRequest { + warren_id: Uuid, + share_id: Uuid, +} + +impl DeleteShareRequest { + pub fn new(warren_id: Uuid, share_id: Uuid) -> Self { + Self { + warren_id, + share_id, + } + } + + pub fn share_id(&self) -> &Uuid { + &self.share_id + } +} + +impl HasWarrenId for DeleteShareRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteShareResponse { + share: Share, +} + +impl DeleteShareResponse { + pub fn new(share: Share) -> Self { + Self { share } + } + + pub fn share(&self) -> &Share { + &self.share + } +} + +impl From for Share { + fn from(value: DeleteShareResponse) -> Self { + value.share + } +} + +#[derive(Debug, Error)] +pub enum DeleteShareError { + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/get.rs b/backend/src/lib/domain/warren/models/share/requests/get.rs new file mode 100644 index 0000000..7bbc632 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/get.rs @@ -0,0 +1,59 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{File, StatError}, + share::Share, + warren::FetchWarrenError, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetShareRequest { + share_id: Uuid, +} + +impl GetShareRequest { + pub fn new(share_id: Uuid) -> Self { + Self { share_id } + } + + pub fn share_id(&self) -> &Uuid { + &self.share_id + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetShareResponse { + share: Share, + file: File, +} + +impl GetShareResponse { + pub fn new(share: Share, file: File) -> Self { + Self { share, file } + } + + pub fn share(&self) -> &Share { + &self.share + } + + pub fn file(&self) -> &File { + &self.file + } +} + +impl From for Share { + fn from(value: GetShareResponse) -> Self { + value.share + } +} + +#[derive(Debug, Error)] +pub enum GetShareError { + #[error(transparent)] + FsStat(#[from] StatError), + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/list.rs b/backend/src/lib/domain/warren/models/share/requests/list.rs new file mode 100644 index 0000000..cf27ac7 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/list.rs @@ -0,0 +1,75 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{AbsoluteFilePath, StatError}, + share::Share, + warren::{FetchWarrenError, FetchWarrenRequest, HasWarrenId, Warren}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListSharesRequest { + warren_id: Uuid, + path: AbsoluteFilePath, +} + +impl ListSharesRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self { + Self { warren_id, path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +impl HasWarrenId for ListSharesRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + +impl Into for &ListSharesRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListSharesResponse { + warren: Warren, + path: AbsoluteFilePath, + shares: Vec, +} + +impl ListSharesResponse { + pub fn new(warren: Warren, path: AbsoluteFilePath, shares: Vec) -> Self { + Self { + warren, + path, + shares, + } + } + + pub fn warren(&self) -> &Warren { + &self.warren + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn shares(&self) -> &Vec { + &self.shares + } +} + +#[derive(Debug, Error)] +pub enum ListSharesError { + #[error(transparent)] + Stat(#[from] StatError), + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/ls.rs b/backend/src/lib/domain/warren/models/share/requests/ls.rs new file mode 100644 index 0000000..35e7a83 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/ls.rs @@ -0,0 +1,100 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{AbsoluteFilePath, LsRequest, LsResponse}, + share::{Share, SharePassword}, + warren::{FetchWarrenError, Warren, WarrenLsError, WarrenLsRequest}, +}; + +use super::{VerifySharePasswordError, VerifySharePasswordRequest}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ShareLsRequest { + share_id: Uuid, + path: AbsoluteFilePath, + password: Option, +} + +impl ShareLsRequest { + pub fn new(share_id: Uuid, path: AbsoluteFilePath, password: Option) -> Self { + Self { + share_id, + path, + password, + } + } + + pub fn share_id(&self) -> &Uuid { + &self.share_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn password(&self) -> Option<&SharePassword> { + self.password.as_ref() + } + + pub fn build_warren_ls_request(self, share: &Share, warren: &Warren) -> WarrenLsRequest { + let include_parent = self.path.as_str() != "/"; + + let path = share.path().clone().join(&self.path.to_relative()); + + WarrenLsRequest::new(*warren.id(), LsRequest::new(path, include_parent)) + } +} + +impl From<&ShareLsRequest> for VerifySharePasswordRequest { + fn from(value: &ShareLsRequest) -> Self { + Self::new(value.share_id, value.password.clone()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ShareLsResponse { + share: Share, + warren: Warren, + path: AbsoluteFilePath, + base: LsResponse, +} + +impl ShareLsResponse { + pub fn new(share: Share, warren: Warren, path: AbsoluteFilePath, base: LsResponse) -> Self { + Self { + share, + warren, + path, + base, + } + } + + pub fn share(&self) -> &Share { + &self.share + } + + pub fn warren(&self) -> &Warren { + &self.warren + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn base(&self) -> &LsResponse { + &self.base + } +} + +#[derive(Debug, Error)] +pub enum ShareLsError { + #[error(transparent)] + VerifySharePassword(#[from] VerifySharePasswordError), + #[error(transparent)] + WarrenLs(#[from] WarrenLsError), + #[error(transparent)] + FetchWarren(#[from] FetchWarrenError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/share/requests/mod.rs b/backend/src/lib/domain/warren/models/share/requests/mod.rs new file mode 100644 index 0000000..0fbab84 --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/mod.rs @@ -0,0 +1,15 @@ +mod cat; +mod create; +mod delete; +mod get; +mod list; +mod ls; +mod verify_password; + +pub use cat::*; +pub use create::*; +pub use delete::*; +pub use get::*; +pub use list::*; +pub use ls::*; +pub use verify_password::*; diff --git a/backend/src/lib/domain/warren/models/share/requests/verify_password.rs b/backend/src/lib/domain/warren/models/share/requests/verify_password.rs new file mode 100644 index 0000000..ddd5abe --- /dev/null +++ b/backend/src/lib/domain/warren/models/share/requests/verify_password.rs @@ -0,0 +1,55 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::share::{Share, SharePassword}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VerifySharePasswordRequest { + share_id: Uuid, + password: Option, +} + +impl VerifySharePasswordRequest { + pub fn new(share_id: Uuid, password: Option) -> Self { + Self { share_id, password } + } + + pub fn share_id(&self) -> &Uuid { + &self.share_id + } + + pub fn password(&self) -> Option<&SharePassword> { + self.password.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VerifySharePasswordResponse { + share: Share, +} + +impl VerifySharePasswordResponse { + pub fn new(share: Share) -> Self { + Self { share } + } + + pub fn share(&self) -> &Share { + &self.share + } +} + +impl From for Share { + fn from(value: VerifySharePasswordResponse) -> Self { + value.share + } +} + +#[derive(Debug, Error)] +pub enum VerifySharePasswordError { + #[error("The specified share does not exist")] + NotFound, + #[error("The specified password is not correct")] + IncorrectPassword, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user_warren/mod.rs b/backend/src/lib/domain/warren/models/user_warren/mod.rs index b255cce..d3d5a4c 100644 --- a/backend/src/lib/domain/warren/models/user_warren/mod.rs +++ b/backend/src/lib/domain/warren/models/user_warren/mod.rs @@ -10,6 +10,10 @@ pub struct UserWarren { can_read_files: bool, can_modify_files: bool, can_delete_files: bool, + can_list_shares: bool, + can_create_shares: bool, + can_modify_shares: bool, + can_delete_shares: bool, } impl UserWarren { @@ -20,6 +24,10 @@ impl UserWarren { can_read_files: bool, can_modify_files: bool, can_delete_files: bool, + can_list_shares: bool, + can_create_shares: bool, + can_modify_shares: bool, + can_delete_shares: bool, ) -> Self { Self { user_id, @@ -28,6 +36,10 @@ impl UserWarren { can_read_files, can_modify_files, can_delete_files, + can_list_shares, + can_create_shares, + can_modify_shares, + can_delete_shares, } } @@ -57,4 +69,20 @@ impl UserWarren { pub fn can_delete_files(&self) -> bool { self.can_delete_files } + + pub fn can_list_shares(&self) -> bool { + self.can_list_shares + } + + pub fn can_create_shares(&self) -> bool { + self.can_create_shares + } + + pub fn can_modify_shares(&self) -> bool { + self.can_modify_shares + } + + pub fn can_delete_shares(&self) -> bool { + self.can_delete_shares + } } diff --git a/backend/src/lib/domain/warren/models/warren/requests.rs b/backend/src/lib/domain/warren/models/warren/requests.rs index 2cbb393..cd85b6c 100644 --- a/backend/src/lib/domain/warren/models/warren/requests.rs +++ b/backend/src/lib/domain/warren/models/warren/requests.rs @@ -16,6 +16,10 @@ use crate::domain::warren::models::file::{ use super::{Warren, WarrenName}; +pub trait HasWarrenId { + fn warren_id(&self) -> &Uuid; +} + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FetchWarrenRequest { id: Uuid, @@ -71,10 +75,6 @@ impl WarrenLsRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &LsRequest { &self.base } @@ -90,6 +90,12 @@ impl WarrenLsRequest { } } +impl HasWarrenId for WarrenLsRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for WarrenLsRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -142,10 +148,6 @@ impl WarrenMkdirRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &MkdirRequest { &self.base } @@ -160,6 +162,12 @@ impl WarrenMkdirRequest { } } +impl HasWarrenId for WarrenMkdirRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for WarrenMkdirRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -207,10 +215,6 @@ impl WarrenRmRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &RmRequest { &self.base } @@ -226,6 +230,12 @@ impl WarrenRmRequest { } } +impl HasWarrenId for WarrenRmRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenRmRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -272,10 +282,6 @@ impl<'s> WarrenSaveRequest<'s> { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &SaveRequest<'s> { &self.base } @@ -292,6 +298,12 @@ impl<'s> WarrenSaveRequest<'s> { } } +impl HasWarrenId for WarrenSaveRequest<'_> { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenSaveRequest<'_> { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -406,10 +418,6 @@ impl WarrenMvRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &MvRequest { &self.base } @@ -423,6 +431,12 @@ impl WarrenMvRequest { } } +impl HasWarrenId for WarrenMvRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenMvRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -578,10 +592,6 @@ impl WarrenCatRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &CatRequest { &self.base } @@ -596,6 +606,12 @@ impl WarrenCatRequest { } } +impl HasWarrenId for WarrenCatRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenCatRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -623,10 +639,6 @@ impl WarrenTouchRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &TouchRequest { &self.base } @@ -641,6 +653,12 @@ impl WarrenTouchRequest { } } +impl HasWarrenId for WarrenTouchRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenTouchRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) @@ -688,10 +706,6 @@ impl WarrenCpRequest { Self { warren_id, base } } - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } - pub fn base(&self) -> &CpRequest { &self.base } @@ -706,6 +720,12 @@ impl WarrenCpRequest { } } +impl HasWarrenId for WarrenCpRequest { + fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + impl Into for &WarrenCpRequest { fn into(self) -> FetchWarrenRequest { FetchWarrenRequest::new(self.warren_id) diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index e2c2a3e..e5e2b15 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -42,6 +42,24 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static { fn record_warren_cp_success(&self) -> impl Future + Send; fn record_warren_cp_failure(&self) -> impl Future + Send; + + fn record_warren_get_share_success(&self) -> impl Future + Send; + fn record_warren_get_share_failure(&self) -> impl Future + Send; + + fn record_warren_share_creation_success(&self) -> impl Future + Send; + fn record_warren_share_creation_failure(&self) -> impl Future + Send; + + fn record_warren_share_list_success(&self) -> impl Future + Send; + fn record_warren_share_list_failure(&self) -> impl Future + Send; + + fn record_warren_share_deletion_success(&self) -> impl Future + Send; + fn record_warren_share_deletion_failure(&self) -> impl Future + Send; + + fn record_warren_share_ls_success(&self) -> impl Future + Send; + fn record_warren_share_ls_failure(&self) -> impl Future + Send; + + fn record_warren_share_cat_success(&self) -> impl Future + Send; + fn record_warren_share_cat_failure(&self) -> impl Future + Send; } pub trait FileSystemMetrics: Clone + Send + Sync + 'static { @@ -68,6 +86,9 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static { fn record_cp_success(&self) -> impl Future + Send; fn record_cp_failure(&self) -> impl Future + Send; + + fn record_stat_success(&self) -> impl Future + Send; + fn record_stat_failure(&self) -> impl Future + Send; } pub trait AuthMetrics: Clone + Send + Sync + 'static { @@ -151,4 +172,13 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_auth_warren_cp_success(&self) -> impl Future + Send; fn record_auth_warren_cp_failure(&self) -> impl Future + Send; + + fn record_auth_share_creation_success(&self) -> impl Future + Send; + fn record_auth_share_creation_failure(&self) -> impl Future + Send; + + fn record_auth_share_list_success(&self) -> impl Future + Send; + fn record_auth_share_list_failure(&self) -> impl Future + Send; + + fn record_auth_share_deletion_success(&self) -> impl Future + Send; + fn record_auth_share_deletion_failure(&self) -> impl Future + Send; } diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 0b4febf..105f6e8 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -17,7 +17,13 @@ use super::models::{ file::{ CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, TouchError, TouchRequest, + SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, + }, + share::{ + CreateShareBaseRequest, CreateShareError, CreateShareRequest, CreateShareResponse, + DeleteShareError, DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest, + GetShareResponse, ListSharesError, ListSharesRequest, ListSharesResponse, ShareCatError, + ShareCatRequest, ShareCatResponse, ShareLsError, ShareLsRequest, ShareLsResponse, }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, @@ -106,6 +112,31 @@ pub trait WarrenService: Clone + Send + Sync + 'static { &self, request: WarrenCpRequest, ) -> impl Future> + Send; + + fn warren_get_share( + &self, + request: GetShareRequest, + ) -> impl Future> + Send; + fn warren_create_share( + &self, + request: CreateShareRequest, + ) -> impl Future> + Send; + fn warren_list_shares( + &self, + request: ListSharesRequest, + ) -> impl Future> + Send; + fn warren_delete_share( + &self, + request: DeleteShareRequest, + ) -> impl Future> + Send; + fn warren_share_ls( + &self, + request: ShareLsRequest, + ) -> impl Future> + Send; + fn warren_share_cat( + &self, + request: ShareCatRequest, + ) -> impl Future> + Send; } pub trait FileSystemService: Clone + Send + Sync + 'static { @@ -121,6 +152,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static { ) -> impl Future> + Send; fn touch(&self, request: TouchRequest) -> impl Future> + Send; fn cp(&self, request: CpRequest) -> impl Future> + Send; + fn stat( + &self, + request: StatRequest, + ) -> impl Future> + Send; } pub trait AuthService: Clone + Send + Sync + 'static { @@ -273,4 +308,20 @@ pub trait AuthService: Clone + Send + Sync + 'static { request: AuthRequest, warren_service: &WS, ) -> impl Future>> + Send; + + fn auth_warren_create_share( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> impl Future>> + Send; + fn auth_warren_list_shares( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> impl Future>> + Send; + fn auth_warren_delete_share( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> impl Future>> + Send; } diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index 8f8208e..fc77b29 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -3,6 +3,10 @@ use uuid::Uuid; use crate::domain::warren::models::{ auth_session::requests::FetchAuthSessionResponse, file::{AbsoluteFilePath, LsResponse}, + share::{ + CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse, + ShareCatResponse, ShareLsResponse, + }, user::{ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ @@ -44,6 +48,22 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static { path: &AbsoluteFilePath, ) -> impl Future + Send; fn warren_cp(&self, response: &WarrenCpResponse) -> impl Future + Send; + + fn got_warren_share(&self, response: &GetShareResponse) -> impl Future + Send; + fn warren_share_created( + &self, + response: &CreateShareResponse, + ) -> impl Future + Send; + fn warren_shares_listed( + &self, + response: &ListSharesResponse, + ) -> impl Future + Send; + fn warren_share_deleted( + &self, + response: &DeleteShareResponse, + ) -> impl Future + Send; + fn warren_share_ls(&self, response: &ShareLsResponse) -> impl Future + Send; + fn warren_share_cat(&self, response: &ShareCatResponse) -> impl Future + Send; } pub trait FileSystemNotifier: Clone + Send + Sync + 'static { @@ -63,6 +83,7 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static { path: &AbsoluteFilePath, target_path: &AbsoluteFilePath, ) -> impl Future + Send; + fn stat(&self, path: &AbsoluteFilePath) -> impl Future + Send; } pub trait AuthNotifier: Clone + Send + Sync + 'static { @@ -175,4 +196,22 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static { user: &User, response: &WarrenCpResponse, ) -> impl Future + Send; + + fn auth_warren_share_created( + &self, + user: &User, + response: &CreateShareResponse, + ) -> impl Future + Send; + + fn auth_warren_shares_listed( + &self, + user: &User, + response: &ListSharesResponse, + ) -> impl Future + Send; + + fn auth_warren_share_deleted( + &self, + user: &User, + response: &DeleteShareResponse, + ) -> impl Future + Send; } diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 3dc27e2..aa89744 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -9,7 +9,13 @@ use crate::domain::warren::models::{ file::{ CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, TouchError, TouchRequest, + SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, + }, + share::{ + CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError, + DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest, ListSharesError, + ListSharesRequest, ListSharesResponse, Share, VerifySharePasswordError, + VerifySharePasswordRequest, VerifySharePasswordResponse, }, user::{ CreateOrUpdateUserOidcError, CreateOrUpdateUserOidcRequest, CreateUserError, @@ -63,6 +69,27 @@ pub trait WarrenRepository: Clone + Send + Sync + 'static { &self, request: FetchWarrenRequest, ) -> impl Future> + Send; + + fn get_warren_share( + &self, + request: GetShareRequest, + ) -> impl Future> + Send; + fn create_warren_share( + &self, + request: CreateShareRequest, + ) -> impl Future> + Send; + fn list_warren_shares( + &self, + request: ListSharesRequest, + ) -> impl Future> + Send; + fn delete_warren_share( + &self, + request: DeleteShareRequest, + ) -> impl Future> + Send; + fn verify_warren_share_password( + &self, + request: VerifySharePasswordRequest, + ) -> impl Future> + Send; } pub trait FileSystemRepository: Clone + Send + Sync + 'static { @@ -78,6 +105,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { ) -> impl Future> + Send; fn touch(&self, request: TouchRequest) -> impl Future> + Send; fn cp(&self, request: CpRequest) -> impl Future> + Send; + fn stat( + &self, + request: StatRequest, + ) -> impl Future> + Send; } pub trait AuthRepository: Clone + Send + Sync + 'static { diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 150aaa1..ca0fa94 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -12,6 +12,11 @@ use crate::{ }, }, file::FileStream, + share::{ + CreateShareBaseRequest, CreateShareError, CreateShareResponse, + DeleteShareError, DeleteShareRequest, DeleteShareResponse, ListSharesError, + ListSharesRequest, ListSharesResponse, + }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest, GetOidcRedirectError, GetOidcRedirectRequest, @@ -32,12 +37,13 @@ use crate::{ warren::{ CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, - 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, + FetchWarrensRequest, HasWarrenId, 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}, @@ -644,17 +650,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; if !user_warren.can_list_files() { return Err(AuthError::InsufficientPermissions); @@ -667,9 +663,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_ls_success().await; - self.notifier - .auth_warren_ls(session_response.user(), response) - .await; + self.notifier.auth_warren_ls(&user, response).await; } else { self.metrics.record_auth_warren_ls_failure().await; } @@ -682,23 +676,14 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - let session_response = self.fetch_auth_session((&request).into()).await?; - - let request = request.into_value(); - let path = request.base().path().clone(); - - let user_warren = self - .repository - .fetch_user_warren(FetchUserWarrenRequest::new( - session_response.user().id().clone(), - request.warren_id().clone(), - )) - .await?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; if !user_warren.can_read_files() { return Err(AuthError::InsufficientPermissions); } + let path = request.base().path().clone(); + let result = warren_service .warren_cat(request) .await @@ -707,7 +692,7 @@ where if let Ok(_stream) = result.as_ref() { self.metrics.record_auth_warren_cat_success().await; self.notifier - .auth_warren_cat(session_response.user(), user_warren.warren_id(), &path) + .auth_warren_cat(&user, user_warren.warren_id(), &path) .await; } else { self.metrics.record_auth_warren_cat_failure().await; @@ -721,17 +706,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; // TODO: Maybe create a separate permission for this if !user_warren.can_modify_files() { @@ -745,9 +720,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_mkdir_success().await; - self.notifier - .auth_warren_mkdir(session_response.user(), response) - .await; + self.notifier.auth_warren_mkdir(&user, response).await; } else { self.metrics.record_auth_warren_mkdir_failure().await; } @@ -760,17 +733,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; if !user_warren.can_delete_files() { return Err(AuthError::InsufficientPermissions); @@ -783,9 +746,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_rm_success().await; - self.notifier - .auth_warren_rm(session_response.user(), response) - .await; + self.notifier.auth_warren_rm(&user, response).await; } else { self.metrics.record_auth_warren_rm_failure().await; } @@ -798,17 +759,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; if !user_warren.can_modify_files() { return Err(AuthError::InsufficientPermissions); @@ -821,9 +772,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_mv_success().await; - self.notifier - .auth_warren_mv(session_response.user(), response) - .await; + self.notifier.auth_warren_mv(&user, response).await; } else { self.metrics.record_auth_warren_mv_failure().await; } @@ -836,17 +785,7 @@ where request: AuthRequest>, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; // TODO: Maybe create a separate permission for this if !user_warren.can_modify_files() { @@ -860,9 +799,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_save_success().await; - self.notifier - .auth_warren_save(session_response.user(), response) - .await; + self.notifier.auth_warren_save(&user, response).await; } else { self.metrics.record_auth_warren_save_failure().await; } @@ -875,17 +812,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; // TODO: Maybe create a separate permission for this if !user_warren.can_modify_files() { @@ -899,9 +826,7 @@ where if let Ok(response) = result.as_ref() { self.metrics.record_auth_warren_touch_success().await; - self.notifier - .auth_warren_touch(session_response.user(), response) - .await; + self.notifier.auth_warren_touch(&user, response).await; } else { self.metrics.record_auth_warren_touch_failure().await; } @@ -914,17 +839,7 @@ where request: AuthRequest, warren_service: &WS, ) -> Result> { - 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?; + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; // TODO: Maybe create a separate permission for this if !user_warren.can_modify_files() { @@ -938,13 +853,128 @@ where 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; + self.notifier.auth_warren_cp(&user, response).await; } else { self.metrics.record_auth_warren_cp_failure().await; } result } + + async fn auth_warren_create_share( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> Result> { + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; + + if !user_warren.can_create_shares() { + return Err(AuthError::InsufficientPermissions); + } + + let result = warren_service + .warren_create_share(request.build_share_request(user.id().clone())) + .await + .map_err(AuthError::Custom); + + if let Ok(response) = result.as_ref() { + self.metrics.record_auth_share_creation_success().await; + self.notifier + .auth_warren_share_created(&user, response) + .await; + } else { + self.metrics.record_auth_share_creation_failure().await; + } + + result + } + + async fn auth_warren_list_shares( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> Result> { + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; + + if !user_warren.can_list_shares() { + return Err(AuthError::InsufficientPermissions); + } + + let result = warren_service + .warren_list_shares(request) + .await + .map_err(AuthError::Custom); + + if let Ok(response) = result.as_ref() { + self.metrics.record_auth_share_list_success().await; + self.notifier + .auth_warren_shares_listed(&user, response) + .await; + } else { + self.metrics.record_auth_share_list_failure().await; + } + + result + } + + async fn auth_warren_delete_share( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> Result> { + let (request, user, user_warren) = self.get_session_data_and_user_warren(request).await?; + + if !user_warren.can_delete_shares() { + return Err(AuthError::InsufficientPermissions); + } + + let result = warren_service + .warren_delete_share(request) + .await + .map_err(AuthError::Custom); + + if let Ok(response) = result.as_ref() { + self.metrics.record_auth_share_deletion_success().await; + self.notifier + .auth_warren_share_deleted(&user, response) + .await; + } else { + self.metrics.record_auth_share_deletion_failure().await; + } + + result + } +} + +impl Service +where + R: AuthRepository, + M: AuthMetrics, + N: AuthNotifier, + OIDC: OidcService, +{ + /// A helper to get a [UserWarren], [User] and the underlying request from an [AuthRequest] + async fn get_session_data_and_user_warren( + &self, + request: AuthRequest, + ) -> Result<(T, User, UserWarren), AuthError> + where + T: HasWarrenId, + E: std::error::Error, + { + let session_response = self.fetch_auth_session((&request).into()).await?; + let user = session_response.unpack().1; + + let request = request.into_value(); + + let user_warren = self + .repository + .fetch_user_warren(FetchUserWarrenRequest::new( + user.id().clone(), + request.warren_id().clone(), + )) + .await?; + + Ok((request, user, user_warren)) + } } diff --git a/backend/src/lib/domain/warren/service/file_system.rs b/backend/src/lib/domain/warren/service/file_system.rs index e8147f5..8d7649f 100644 --- a/backend/src/lib/domain/warren/service/file_system.rs +++ b/backend/src/lib/domain/warren/service/file_system.rs @@ -2,7 +2,7 @@ use crate::domain::warren::{ models::file::{ CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, TouchError, TouchRequest, + SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, }, ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService}, }; @@ -152,4 +152,18 @@ where result } + + async fn stat(&self, request: StatRequest) -> Result { + let path = request.path().clone(); + let result = self.repository.stat(request).await; + + if result.is_ok() { + self.metrics.record_stat_success().await; + self.notifier.stat(&path).await; + } else { + self.metrics.record_stat_failure().await; + } + + result + } } diff --git a/backend/src/lib/domain/warren/service/warren.rs b/backend/src/lib/domain/warren/service/warren.rs index 94167fe..fd6065e 100644 --- a/backend/src/lib/domain/warren/service/warren.rs +++ b/backend/src/lib/domain/warren/service/warren.rs @@ -1,6 +1,13 @@ use crate::domain::warren::{ models::{ - file::FileStream, + file::{File, FileStream}, + share::{ + CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError, + DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest, + GetShareResponse, ListSharesError, ListSharesRequest, ListSharesResponse, Share, + ShareCatError, ShareCatRequest, ShareCatResponse, ShareLsError, ShareLsRequest, + ShareLsResponse, + }, warren::{ CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrensError, FetchWarrensRequest, @@ -335,4 +342,199 @@ where result } + + async fn warren_get_share( + &self, + request: GetShareRequest, + ) -> Result { + let share = match self.repository.get_warren_share(request).await { + Ok(share) => share, + Err(e) => { + self.metrics.record_warren_get_share_failure().await; + return Err(e); + } + }; + + let warren = match self + .repository + .fetch_warren(FetchWarrenRequest::new(*share.warren_id())) + .await + { + Ok(warren) => warren, + Err(e) => { + self.metrics.record_warren_get_share_failure().await; + return Err(e.into()); + } + }; + + let file: File = match self + .fs_service + .stat(share.build_fs_stat_request(&warren)) + .await + { + Ok(stat) => stat.into(), + Err(e) => { + self.metrics.record_warren_get_share_failure().await; + return Err(e.into()); + } + }; + + let response = GetShareResponse::new(share, file); + + self.metrics.record_warren_get_share_success().await; + self.notifier.got_warren_share(&response).await; + + Ok(response) + } + + async fn warren_create_share( + &self, + request: CreateShareRequest, + ) -> Result { + let warren = self.repository.fetch_warren((&request).into()).await?; + let stat_request = request.build_fs_stat_request(&warren); + + self.fs_service.stat(stat_request).await?; + + let result = self.repository.create_warren_share(request).await; + + if let Ok(response) = result.as_ref() { + self.metrics.record_warren_share_creation_success().await; + self.notifier.warren_share_created(response).await; + } else { + self.metrics.record_warren_share_creation_failure().await; + } + + result + } + + async fn warren_list_shares( + &self, + request: ListSharesRequest, + ) -> Result { + let result = self.repository.list_warren_shares(request).await; + + if let Ok(response) = result.as_ref() { + self.metrics.record_warren_share_list_success().await; + self.notifier.warren_shares_listed(response).await; + } else { + self.metrics.record_warren_share_list_failure().await; + } + + result + } + + async fn warren_delete_share( + &self, + request: DeleteShareRequest, + ) -> Result { + let result = self.repository.delete_warren_share(request).await; + + if let Ok(response) = result.as_ref() { + self.metrics.record_warren_share_deletion_success().await; + self.notifier.warren_share_deleted(response).await; + } else { + self.metrics.record_warren_share_deletion_failure().await; + } + + result + } + + async fn warren_share_ls( + &self, + request: ShareLsRequest, + ) -> Result { + let share: Share = match self + .repository + .verify_warren_share_password((&request).into()) + .await + { + Ok(response) => response.into(), + Err(e) => { + self.metrics.record_warren_share_ls_failure().await; + return Err(e.into()); + } + }; + + let warren = match self + .repository + .fetch_warren(FetchWarrenRequest::new(*share.warren_id())) + .await + { + Ok(warren) => warren, + Err(e) => { + self.metrics.record_warren_share_ls_failure().await; + return Err(e.into()); + } + }; + + let path = request.path().clone(); + + let ls_response = match self + .warren_ls(request.build_warren_ls_request(&share, &warren)) + .await + { + Ok(response) => response, + Err(e) => { + self.metrics.record_warren_share_ls_failure().await; + return Err(e.into()); + } + }; + + let response = ShareLsResponse::new(share, warren, path, ls_response.base().clone()); + + self.metrics.record_warren_share_ls_success().await; + self.notifier.warren_share_ls(&response).await; + + Ok(response) + } + + async fn warren_share_cat( + &self, + request: ShareCatRequest, + ) -> Result { + let share: Share = match self + .repository + .verify_warren_share_password((&request).into()) + .await + { + Ok(response) => response.into(), + Err(e) => { + self.metrics.record_warren_share_cat_failure().await; + return Err(e.into()); + } + }; + + let warren = match self + .repository + .fetch_warren(FetchWarrenRequest::new(*share.warren_id())) + .await + { + Ok(warren) => warren, + Err(e) => { + self.metrics.record_warren_share_cat_failure().await; + return Err(e.into()); + } + }; + + let path = request.path().clone(); + + let stream = match self + .warren_cat(request.build_warren_cat_request(&share, &warren)) + .await + { + Ok(response) => response, + Err(e) => { + self.metrics.record_warren_share_cat_failure().await; + return Err(e.into()); + } + }; + + let response = ShareCatResponse::new(share, warren, path, stream); + + self.metrics.record_warren_share_cat_success().await; + self.notifier.warren_share_cat(&response).await; + + Ok(response) + } } diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index 61d9191..156efa9 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -1,15 +1,16 @@ use crate::{ domain::warren::models::{ auth_session::{AuthError, requests::FetchAuthSessionError}, - file::{LsError, MkdirError, RmError}, + file::{CatError, LsError, MkdirError, RmError, StatError}, + share::{GetShareError, ShareCatError, ShareLsError, VerifySharePasswordError}, user::{ CreateUserError, GetOidcRedirectError, LoginUserError, LoginUserOidcError, RegisterUserError, VerifyUserPasswordError, }, user_warren::requests::FetchUserWarrenError, warren::{ - FetchWarrenError, FetchWarrensError, WarrenLsError, WarrenMkdirError, WarrenMvError, - WarrenRmError, WarrenSaveError, + FetchWarrenError, FetchWarrensError, WarrenCatError, WarrenLsError, WarrenMkdirError, + WarrenMvError, WarrenRmError, WarrenSaveError, }, }, inbound::http::responses::ApiError, @@ -191,7 +192,7 @@ impl From> for ApiError { impl From for ApiError { fn from(value: CreateUserError) -> Self { match value { - CreateUserError::Unknown(err) => Self::InternalServerError(err.to_string()), + CreateUserError::Unknown(err) => err.into(), } } } @@ -205,7 +206,63 @@ impl From for ApiError { } }, GetOidcRedirectError::Disabled => Self::BadRequest("OIDC is disabled".to_string()), - GetOidcRedirectError::Unknown(e) => Self::InternalServerError(e.to_string()), + GetOidcRedirectError::Unknown(e) => e.into(), + } + } +} + +impl From for ApiError { + fn from(value: GetShareError) -> Self { + match value { + GetShareError::FetchWarren(err) => err.into(), + GetShareError::FsStat(err) => match err { + StatError::NotFound => Self::NotFound("The share no longer exists".to_string()), + StatError::Unknown(err) => err.into(), + }, + GetShareError::Unknown(err) => err.into(), + } + } +} + +impl From for ApiError { + fn from(value: VerifySharePasswordError) -> Self { + match value { + VerifySharePasswordError::NotFound => { + Self::NotFound("Could not find the specified share".to_string()) + } + VerifySharePasswordError::IncorrectPassword => { + Self::BadRequest("Incorrect password".to_string()) + } + VerifySharePasswordError::Unknown(error) => error.into(), + } + } +} + +impl From for ApiError { + fn from(value: ShareLsError) -> Self { + match value { + ShareLsError::VerifySharePassword(err) => err.into(), + ShareLsError::WarrenLs(err) => err.into(), + ShareLsError::FetchWarren(err) => err.into(), + ShareLsError::Unknown(err) => err.into(), + } + } +} + +impl From for ApiError { + fn from(value: ShareCatError) -> Self { + match value { + ShareCatError::VerifySharePassword(err) => err.into(), + ShareCatError::FetchWarren(err) => err.into(), + ShareCatError::WarrenCat(err) => match err { + WarrenCatError::FetchWarren(err) => err.into(), + WarrenCatError::FileSystem(err) => match err { + CatError::NotFound => Self::NotFound("This file does not exist".to_string()), + CatError::Unknown(err) => err.into(), + }, + WarrenCatError::Unknown(err) => err.into(), + }, + ShareCatError::Unknown(err) => err.into(), } } } diff --git a/backend/src/lib/inbound/http/handlers/admin/create_user_warren.rs b/backend/src/lib/inbound/http/handlers/admin/create_user_warren.rs index b0c47b9..8b42564 100644 --- a/backend/src/lib/inbound/http/handlers/admin/create_user_warren.rs +++ b/backend/src/lib/inbound/http/handlers/admin/create_user_warren.rs @@ -26,6 +26,10 @@ pub(super) struct CreateUserWarrenHttpRequestBody { can_read_files: bool, can_modify_files: bool, can_delete_files: bool, + can_list_shares: bool, + can_create_shares: bool, + can_modify_shares: bool, + can_delete_shares: bool, } impl CreateUserWarrenHttpRequestBody { @@ -37,6 +41,10 @@ impl CreateUserWarrenHttpRequestBody { self.can_read_files, self.can_modify_files, self.can_delete_files, + self.can_list_shares, + self.can_create_shares, + self.can_modify_shares, + self.can_delete_shares, )) } } diff --git a/backend/src/lib/inbound/http/handlers/admin/edit_user_warren.rs b/backend/src/lib/inbound/http/handlers/admin/edit_user_warren.rs index bd5e8bb..d3e9347 100644 --- a/backend/src/lib/inbound/http/handlers/admin/edit_user_warren.rs +++ b/backend/src/lib/inbound/http/handlers/admin/edit_user_warren.rs @@ -26,6 +26,10 @@ pub(super) struct EditUserWarrenHttpRequestBody { can_read_files: bool, can_modify_files: bool, can_delete_files: bool, + can_list_shares: bool, + can_create_shares: bool, + can_modify_shares: bool, + can_delete_shares: bool, } impl EditUserWarrenHttpRequestBody { @@ -37,6 +41,10 @@ impl EditUserWarrenHttpRequestBody { self.can_read_files, self.can_modify_files, self.can_delete_files, + self.can_list_shares, + self.can_create_shares, + self.can_modify_shares, + self.can_delete_shares, )) } } diff --git a/backend/src/lib/inbound/http/handlers/extractors.rs b/backend/src/lib/inbound/http/handlers/extractors.rs index 1e58136..9004c1d 100644 --- a/backend/src/lib/inbound/http/handlers/extractors.rs +++ b/backend/src/lib/inbound/http/handlers/extractors.rs @@ -5,7 +5,8 @@ use axum::{ use axum_extra::extract::CookieJar; use crate::{ - domain::warren::models::auth_session::AuthSessionIdWithType, inbound::http::responses::ApiError, + domain::warren::models::{auth_session::AuthSessionIdWithType, share::SharePassword}, + inbound::http::responses::ApiError, }; pub struct SessionIdHeader(pub AuthSessionIdWithType); @@ -51,3 +52,48 @@ where } } } + +pub struct SharePasswordHeader(pub Option); + +impl SharePasswordHeader { + const NAME: &str = "X-Share-Password"; +} + +impl FromRequestParts for SharePasswordHeader +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // Release build + if !cfg!(debug_assertions) { + let jar = CookieJar::from_request_parts(parts, state).await.unwrap(); + + let Some(cookie) = jar.get(Self::NAME) else { + return Ok(Self(None)); + }; + + SharePassword::new(cookie.value()) + .map(|v| Self(Some(v))) + .map_err(|_| ApiError::BadRequest("Invalid password".to_string())) + } + // Debug build + else { + let Some(header) = parts.headers.get(Self::NAME) else { + return Ok(Self(None)); + }; + + let header_str = header.to_str().map_err(|_| { + ApiError::InternalServerError(format!( + "Failed to get {} header as string", + Self::NAME + )) + })?; + + SharePassword::new(header_str) + .map(|v| Self(Some(v))) + .map_err(|_| ApiError::BadRequest("Invalid share password".to_string())) + } + } +} diff --git a/backend/src/lib/inbound/http/handlers/mod.rs b/backend/src/lib/inbound/http/handlers/mod.rs index 2cab43e..071020b 100644 --- a/backend/src/lib/inbound/http/handlers/mod.rs +++ b/backend/src/lib/inbound/http/handlers/mod.rs @@ -1,7 +1,9 @@ use serde::Serialize; use uuid::Uuid; -use crate::domain::warren::models::{user::User, user_warren::UserWarren, warren::Warren}; +use crate::domain::warren::models::{ + share::Share, user::User, user_warren::UserWarren, warren::Warren, +}; pub mod admin; pub mod auth; @@ -39,6 +41,10 @@ pub(super) struct UserWarrenData { can_read_files: bool, can_modify_files: bool, can_delete_files: bool, + can_list_shares: bool, + can_create_shares: bool, + can_modify_shares: bool, + can_delete_shares: bool, } impl From for UserWarrenData { @@ -50,23 +56,10 @@ impl From for UserWarrenData { can_read_files: value.can_read_files(), can_modify_files: value.can_modify_files(), can_delete_files: value.can_delete_files(), - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -/// A warren that can be safely sent to the client -pub(super) struct WarrenData { - id: Uuid, - name: String, -} - -impl From for WarrenData { - fn from(value: Warren) -> Self { - Self { - id: *value.id(), - name: value.name().to_string(), + can_list_shares: value.can_list_shares(), + can_create_shares: value.can_create_shares(), + can_modify_shares: value.can_modify_shares(), + can_delete_shares: value.can_delete_shares(), } } } @@ -89,3 +82,37 @@ impl From for AdminWarrenData { } } } + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(super) struct ShareData { + id: Uuid, + creator_id: Uuid, + + warren_id: Uuid, + path: String, + + password: bool, + + expires_at: Option, + + created_at: u64, +} + +impl From for ShareData +where + T: Into, +{ + fn from(value: T) -> Self { + let value: Share = value.into(); + Self { + id: *value.id(), + creator_id: *value.creator_id(), + warren_id: *value.warren_id(), + path: value.path().to_string(), + password: value.password_hash().is_some(), + expires_at: value.expires_at(), + created_at: value.created_at(), + } + } +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/cat_share.rs b/backend/src/lib/inbound/http/handlers/warrens/cat_share.rs new file mode 100644 index 0000000..ee2481b --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/cat_share.rs @@ -0,0 +1,86 @@ +use axum::{ + body::Body, + extract::{Query, State}, +}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::{ + file::{AbsoluteFilePathError, FilePath, FilePathError, FileStream}, + share::{ShareCatRequest, SharePassword, SharePasswordError}, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{AppState, handlers::extractors::SharePasswordHeader, responses::ApiError}, +}; + +#[derive(Debug, Error)] +enum ParseShareCatHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + Password(#[from] SharePasswordError), +} + +impl From for ApiError { + fn from(value: ParseShareCatHttpRequestError) -> Self { + match value { + ParseShareCatHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => Self::BadRequest("The path is invalid".to_string()), + }, + ParseShareCatHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + Self::BadRequest("The path must be absolute".to_string()) + } + }, + ParseShareCatHttpRequestError::Password(err) => Self::BadRequest( + match err { + SharePasswordError::Empty => "The provided password is empty", + SharePasswordError::LeadingWhitespace + | SharePasswordError::TrailingWhitespace + | SharePasswordError::TooShort + | SharePasswordError::TooLong => "", + } + .to_string(), + ), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ShareCatHttpRequestBody { + share_id: Uuid, + path: String, +} + +impl ShareCatHttpRequestBody { + fn try_into_domain( + self, + password: Option, + ) -> Result { + let path = FilePath::new(&self.path)?.try_into()?; + + Ok(ShareCatRequest::new(self.share_id, path, password)) + } +} + +pub async fn cat_share( + State(state): State>, + SharePasswordHeader(password): SharePasswordHeader, + Query(request): Query, +) -> Result { + let domain_request = request.try_into_domain(password)?; + + state + .warren_service + .warren_share_cat(domain_request) + .await + .map(|response| FileStream::from(response).into()) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/create_share.rs b/backend/src/lib/inbound/http/handlers/warrens/create_share.rs new file mode 100644 index 0000000..accff06 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/create_share.rs @@ -0,0 +1,87 @@ +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, FilePath, FilePathError}, + share::{CreateShareBaseRequest, SharePassword, SharePasswordError}, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::{ShareData, extractors::SessionIdHeader}, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateShareHttpRequestBody { + warren_id: Uuid, + path: String, + lifetime: Option, + password: Option, +} + +#[derive(Debug, Error)] +enum ParseCreateShareHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + SharePassword(#[from] SharePasswordError), +} + +impl From for ApiError { + fn from(e: ParseCreateShareHttpRequestError) -> Self { + ApiError::BadRequest(e.to_string()) + } +} + +impl CreateShareHttpRequestBody { + fn try_into_domain(self) -> Result { + let path: AbsoluteFilePath = { + let file_path = FilePath::new(&self.path)?; + file_path.try_into()? + }; + + let password = if let Some(password) = self.password { + Some(SharePassword::new(&password)?) + } else { + None + }; + + Ok(CreateShareBaseRequest::new( + self.warren_id, + path, + password, + self.lifetime, + )) + } +} + +pub async fn create_share( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = AuthRequest::new(session, request.try_into_domain()?); + + state + .auth_service + .auth_warren_create_share(domain_request, state.warren_service.as_ref()) + .await + .map(|response| { + ApiSuccess::new( + StatusCode::CREATED, + ShareData::from(response.share().clone()), + ) + }) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/delete_share.rs b/backend/src/lib/inbound/http/handlers/warrens/delete_share.rs new file mode 100644 index 0000000..1d82ee0 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/delete_share.rs @@ -0,0 +1,43 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::{auth_session::AuthRequest, share::DeleteShareRequest}, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::{ShareData, extractors::SessionIdHeader}, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeleteShareHttpRequestBody { + warren_id: Uuid, + share_id: Uuid, +} + +impl DeleteShareHttpRequestBody { + fn into_domain(self) -> DeleteShareRequest { + DeleteShareRequest::new(self.warren_id, self.share_id) + } +} + +pub async fn delete_share( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = AuthRequest::new(session, request.into_domain()); + + state + .auth_service + .auth_warren_delete_share(domain_request, state.warren_service.as_ref()) + .await + .map(|response| ApiSuccess::new(StatusCode::OK, ShareData::from(response))) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/get_share.rs b/backend/src/lib/inbound/http/handlers/warrens/get_share.rs new file mode 100644 index 0000000..c02f892 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/get_share.rs @@ -0,0 +1,59 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::share::{GetShareRequest, GetShareResponse}, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::ShareData, + responses::{ApiError, ApiSuccess}, + }, +}; + +use super::WarrenFileElement; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct GetShareHttpRequestBody { + share_id: Uuid, +} + +impl GetShareHttpRequestBody { + fn into_domain(self) -> GetShareRequest { + GetShareRequest::new(self.share_id) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(super) struct GetShareHttpResponseBody { + share: ShareData, + file: WarrenFileElement, +} + +impl From for GetShareHttpResponseBody { + fn from(value: GetShareResponse) -> Self { + Self { + share: value.share().clone().into(), + file: value.file().into(), + } + } +} + +pub async fn get_share( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.into_domain(); + + state + .warren_service + .warren_get_share(domain_request) + .await + .map(|response| ApiSuccess::new(StatusCode::OK, response.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/list_shares.rs b/backend/src/lib/inbound/http/handlers/warrens/list_shares.rs new file mode 100644 index 0000000..6521c1f --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/list_shares.rs @@ -0,0 +1,77 @@ +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, FilePath, FilePathError}, + share::ListSharesRequest, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::{ShareData, extractors::SessionIdHeader}, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ListSharesHttpRequestBody { + warren_id: Uuid, + path: String, +} + +#[derive(Debug, Error)] +enum ParseListSharesHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl From for ApiError { + fn from(e: ParseListSharesHttpRequestError) -> Self { + ApiError::BadRequest(e.to_string()) + } +} + +impl ListSharesHttpRequestBody { + fn try_into_domain(self) -> Result { + let path: AbsoluteFilePath = { + let file_path = FilePath::new(&self.path)?; + file_path.try_into()? + }; + + Ok(ListSharesRequest::new(self.warren_id, path)) + } +} + +pub async fn list_shares( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Json(request): Json, +) -> Result>, ApiError> { + let domain_request = AuthRequest::new(session, request.try_into_domain()?); + + state + .auth_service + .auth_warren_list_shares(domain_request, state.warren_service.as_ref()) + .await + .map(|response| { + ApiSuccess::new( + StatusCode::OK, + response + .shares() + .into_iter() + .cloned() + .map(ShareData::from) + .collect(), + ) + }) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/ls_share.rs b/backend/src/lib/inbound/http/handlers/warrens/ls_share.rs new file mode 100644 index 0000000..cabc758 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/ls_share.rs @@ -0,0 +1,111 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::{ + file::{AbsoluteFilePathError, FilePath, FilePathError}, + share::{ShareLsRequest, ShareLsResponse, SharePassword, SharePasswordError}, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +use super::WarrenFileElement; + +#[derive(Debug, Error)] +enum ParseShareLsHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + Password(#[from] SharePasswordError), +} + +impl From for ApiError { + fn from(value: ParseShareLsHttpRequestError) -> Self { + match value { + ParseShareLsHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => Self::BadRequest("The path is invalid".to_string()), + }, + ParseShareLsHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + Self::BadRequest("The path must be absolute".to_string()) + } + }, + ParseShareLsHttpRequestError::Password(err) => Self::BadRequest( + match err { + SharePasswordError::Empty => "The provided password is empty", + SharePasswordError::LeadingWhitespace + | SharePasswordError::TrailingWhitespace + | SharePasswordError::TooShort + | SharePasswordError::TooLong => "", + } + .to_string(), + ), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct LsShareHttpRequestBody { + share_id: Uuid, + path: String, + password: Option, +} + +impl LsShareHttpRequestBody { + fn try_into_domain(self) -> Result { + let path = FilePath::new(&self.path)?.try_into()?; + let password = if let Some(password) = self.password.as_ref() { + Some(SharePassword::new(password)?) + } else { + None + }; + + Ok(ShareLsRequest::new(self.share_id, path, password)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareLsResponseData { + files: Vec, + parent: Option, +} + +impl From for ShareLsResponseData { + fn from(value: ShareLsResponse) -> Self { + Self { + files: value + .base() + .files() + .into_iter() + .map(WarrenFileElement::from) + .collect(), + parent: value.base().parent().map(WarrenFileElement::from), + } + } +} + +pub async fn ls_share( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .warren_service + .warren_share_ls(domain_request) + .await + .map(|response| ApiSuccess::new(StatusCode::OK, response.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 cbcab8c..0bbb92d 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/mod.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -1,5 +1,11 @@ +mod cat_share; +mod create_share; +mod delete_share; mod fetch_warren; +mod get_share; +mod list_shares; mod list_warrens; +mod ls_share; mod upload_warren_files; mod warren_cat; mod warren_cp; @@ -14,23 +20,53 @@ use axum::{ routing::{get, post}, }; -use crate::{ - domain::warren::ports::{AuthService, WarrenService}, - inbound::http::AppState, -}; - use fetch_warren::fetch_warren; use list_warrens::list_warrens; +use serde::Serialize; use warren_ls::list_warren_files; use warren_mkdir::create_warren_directory; use warren_rm::warren_rm; +use cat_share::cat_share; +use create_share::create_share; +use delete_share::delete_share; +use get_share::get_share; +use list_shares::list_shares; +use ls_share::ls_share; use upload_warren_files::warren_save; use warren_cat::fetch_file; use warren_cp::warren_cp; use warren_mv::warren_mv; +use crate::{ + domain::warren::{ + models::file::{File, FileMimeType, FileType}, + ports::{AuthService, WarrenService}, + }, + inbound::http::AppState, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WarrenFileElement { + name: String, + file_type: FileType, + mime_type: Option, + created_at: Option, +} + +impl From<&File> for WarrenFileElement { + fn from(value: &File) -> Self { + Self { + name: value.name().to_string(), + file_type: value.file_type().to_owned(), + mime_type: value.mime_type().map(FileMimeType::to_string), + created_at: value.created_at(), + } + } +} + pub fn routes() -> Router> { Router::new() .route("/", get(list_warrens)) @@ -46,4 +82,10 @@ pub fn routes() -> Router> ) .route("/files/mv", post(warren_mv)) .route("/files/cp", post(warren_cp)) + .route("/files/create_share", post(create_share)) + .route("/files/list_shares", post(list_shares)) + .route("/files/delete_share", post(delete_share)) + .route("/files/get_share", post(get_share)) + .route("/files/ls_share", post(ls_share)) + .route("/files/cat_share", get(cat_share)) } diff --git a/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs b/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs index 5d36937..46c20c9 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs @@ -2,8 +2,7 @@ use axum::{ body::Body, extract::{Query, State}, }; -use base64::Engine as _; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use thiserror::Error; use tokio_util::io::ReaderStream; use uuid::Uuid; @@ -20,21 +19,6 @@ use crate::{ 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(super) struct WarrenCatHttpRequestBody { diff --git a/backend/src/lib/inbound/http/handlers/warrens/warren_ls.rs b/backend/src/lib/inbound/http/handlers/warrens/warren_ls.rs index 5feecd5..62146ed 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/warren_ls.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/warren_ls.rs @@ -7,10 +7,7 @@ use crate::{ domain::warren::{ models::{ auth_session::AuthRequest, - file::{ - AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType, - LsRequest, - }, + file::{AbsoluteFilePathError, FilePath, FilePathError, LsRequest}, warren::{WarrenLsRequest, WarrenLsResponse}, }, ports::{AuthService, WarrenService}, @@ -22,6 +19,8 @@ use crate::{ }, }; +use super::WarrenFileElement; + #[derive(Debug, Clone, Error)] pub enum ParseWarrenLsHttpRequestError { #[error(transparent)] @@ -65,15 +64,6 @@ impl From for ApiError { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WarrenFileElement { - name: String, - file_type: FileType, - mime_type: Option, - created_at: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListWarrenFilesResponseData { @@ -81,17 +71,6 @@ pub struct ListWarrenFilesResponseData { parent: Option, } -impl From<&File> for WarrenFileElement { - fn from(value: &File) -> Self { - Self { - name: value.name().to_string(), - file_type: value.file_type().to_owned(), - mime_type: value.mime_type().map(FileMimeType::to_string), - created_at: value.created_at(), - } - } -} - impl From for ListWarrenFilesResponseData { fn from(value: WarrenLsResponse) -> Self { Self { diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index 418ec03..fc99ffa 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -1,8 +1,8 @@ -use std::os::unix::fs::MetadataExt; +use std::{os::unix::fs::MetadataExt, path::PathBuf}; use anyhow::{Context, anyhow, bail}; use futures_util::TryStreamExt; -use rustix::fs::statx; +use rustix::fs::{Statx, statx}; use tokio::{ fs, io::{self, AsyncWriteExt as _}, @@ -17,7 +17,8 @@ use crate::{ 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, + RmError, RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, + StatResponse, TouchError, TouchRequest, }, warren::UploadFileStream, }, @@ -242,7 +243,12 @@ impl FileSystem { async fn touch(&self, path: &AbsoluteFilePath) -> io::Result<()> { let path = self.get_target_path(path); - fs::File::create(&path).await.map(|_| ()) + fs::OpenOptions::new() + .create(true) + .write(true) + .open(&path) + .await + .map(|_| ()) } async fn cp( @@ -257,6 +263,46 @@ impl FileSystem { Ok(CpResponse::new(path, target_path)) } + + async fn stat(&self, path: AbsoluteFilePath) -> anyhow::Result { + let target_path = self.get_target_path(&path); + let fs_path = PathBuf::from(target_path.to_string()); + + let metadata = fs::metadata(&fs_path).await?; + + let name = { + let file_name = fs_path + .clone() + .file_name() + .context("Failed to get file name")? + .to_owned() + .into_string() + .ok() + .context("Failed to get file name")?; + FileName::new(&file_name)? + }; + + let file_type = { + let file_type = metadata.file_type(); + + if file_type.is_dir() { + FileType::Directory + } else if file_type.is_file() { + FileType::File + } else { + bail!("Invalid file type"); + } + }; + + let mime_type = match file_type { + FileType::File => FileMimeType::from_name(name.as_str()), + _ => None, + }; + + let created_at = get_btime(&fs_path); + + Ok(File::new(name, file_type, mime_type, created_at)) + } } impl FileSystemRepository for FileSystem { @@ -333,11 +379,25 @@ impl FileSystemRepository for FileSystem { .await .map_err(|e| CpError::Unknown(e.into())) } + + async fn stat(&self, request: StatRequest) -> Result { + let path = request.into_path(); + Ok(self.stat(path).await.map(StatResponse::new)?) + } } // TODO: Use `DirEntry::metadata` once `target=x86_64-unknown-linux-musl` updates from musl 1.2.3 to 1.2.5 // https://github.com/rust-lang/rust/pull/142682 fn get_btime

(path: P) -> Option +where + P: rustix::path::Arg, +{ + get_statx(path) + .ok() + .map(|statx| statx.stx_btime.tv_sec as u64) +} + +fn get_statx

(path: P) -> rustix::io::Result where P: rustix::path::Arg, { @@ -349,6 +409,4 @@ where rustix::fs::StatxFlags::BTIME, ) } - .ok() - .map(|statx| statx.stx_btime.tv_sec as u64) } diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index bdd1ef1..3e2134a 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -110,6 +110,47 @@ impl WarrenMetrics for MetricsDebugLogger { async fn record_warren_cp_failure(&self) { tracing::debug!("[Metrics] Warren entry cp failed"); } + + async fn record_warren_get_share_success(&self) { + tracing::debug!("[Metrics] Warren get share succeeded"); + } + async fn record_warren_get_share_failure(&self) { + tracing::debug!("[Metrics] Warren get share failed"); + } + + async fn record_warren_share_creation_success(&self) { + tracing::debug!("[Metrics] Warren share creation succeeded"); + } + async fn record_warren_share_creation_failure(&self) { + tracing::debug!("[Metrics] Warren share creation failed"); + } + + async fn record_warren_share_list_success(&self) { + tracing::debug!("[Metrics] Warren share list succeeded"); + } + async fn record_warren_share_list_failure(&self) { + tracing::debug!("[Metrics] Warren share list failed"); + } + async fn record_warren_share_deletion_success(&self) { + tracing::debug!("[Metrics] Warren share deletion succeeded"); + } + async fn record_warren_share_deletion_failure(&self) { + tracing::debug!("[Metrics] Warren share deletion failed"); + } + + async fn record_warren_share_ls_success(&self) { + tracing::debug!("[Metrics] Warren share ls succeeded"); + } + async fn record_warren_share_ls_failure(&self) { + tracing::debug!("[Metrics] Warren share ls failed"); + } + + async fn record_warren_share_cat_success(&self) { + tracing::debug!("[Metrics] Warren share cat succeeded"); + } + async fn record_warren_share_cat_failure(&self) { + tracing::debug!("[Metrics] Warren share cat failed"); + } } impl FileSystemMetrics for MetricsDebugLogger { @@ -168,6 +209,13 @@ impl FileSystemMetrics for MetricsDebugLogger { async fn record_cp_failure(&self) { tracing::debug!("[Metrics] Cp failed"); } + + async fn record_stat_success(&self) { + tracing::debug!("[Metrics] Stat succeeded"); + } + async fn record_stat_failure(&self) { + tracing::debug!("[Metrics] Stat failed"); + } } impl AuthMetrics for MetricsDebugLogger { @@ -359,6 +407,27 @@ impl AuthMetrics for MetricsDebugLogger { async fn record_auth_warren_cp_failure(&self) { tracing::debug!("[Metrics] Auth warren cp failed"); } + + async fn record_auth_share_creation_success(&self) { + tracing::debug!("[Metrics] Auth warren share creation succeeded"); + } + async fn record_auth_share_creation_failure(&self) { + tracing::debug!("[Metrics] Auth warren share creation failed"); + } + + async fn record_auth_share_list_success(&self) { + tracing::debug!("[Metrics] Auth warren share list succeeded"); + } + async fn record_auth_share_list_failure(&self) { + tracing::debug!("[Metrics] Auth warren share list failed"); + } + + async fn record_auth_share_deletion_success(&self) { + tracing::debug!("[Metrics] Auth warren share deletion succeeded"); + } + async fn record_auth_share_deletion_failure(&self) { + tracing::debug!("[Metrics] Auth warren share deletion failed"); + } } impl OidcMetrics for MetricsDebugLogger { diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 4887359..6476195 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -9,6 +9,10 @@ use crate::domain::{ models::{ auth_session::requests::FetchAuthSessionResponse, file::{AbsoluteFilePath, LsResponse}, + share::{ + CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse, + ShareCatResponse, ShareLsResponse, + }, user::{ ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User, }, @@ -115,6 +119,57 @@ impl WarrenNotifier for NotifierDebugLogger { response.warren().name() ); } + + async fn warren_share_created(&self, response: &CreateShareResponse) { + tracing::debug!( + "[Notifier] Created share for file {} in warren {}", + response.share().path(), + response.share().warren_id(), + ); + } + + async fn warren_shares_listed(&self, response: &ListSharesResponse) { + tracing::debug!( + "[Notifier] Listed {} share(s) for file {} in warren {}", + response.shares().len(), + response.path(), + response.warren().id(), + ); + } + + async fn warren_share_deleted(&self, response: &DeleteShareResponse) { + tracing::debug!( + "[Notifier] Deleted share {} for file {} in warren {}", + response.share().id(), + response.share().path(), + response.share().warren_id(), + ); + } + + async fn got_warren_share(&self, response: &GetShareResponse) { + tracing::debug!( + "[Notifier] Got share {} from warren {}", + response.share().id(), + response.share().warren_id() + ); + } + + async fn warren_share_ls(&self, response: &ShareLsResponse) { + tracing::debug!( + "[Notifier] Listed {} file(s) in share {} at path {}", + response.base().files().len(), + response.share().id(), + response.path() + ); + } + + async fn warren_share_cat(&self, response: &ShareCatResponse) { + tracing::debug!( + "[Notifier] Fetched file {} from share {}", + response.path(), + response.share().id(), + ); + } } impl FileSystemNotifier for NotifierDebugLogger { @@ -149,6 +204,10 @@ impl FileSystemNotifier for NotifierDebugLogger { async fn cp(&self, path: &AbsoluteFilePath, target_path: &AbsoluteFilePath) { tracing::debug!("[Notifier] Copied file {} to {}", path, target_path); } + + async fn stat(&self, path: &AbsoluteFilePath) { + tracing::debug!("[Notifier] Got stats for file {}", path); + } } impl AuthNotifier for NotifierDebugLogger { @@ -368,6 +427,35 @@ impl AuthNotifier for NotifierDebugLogger { user.id() ) } + + async fn auth_warren_share_created(&self, user: &User, response: &CreateShareResponse) { + tracing::debug!( + "[Notifier] Created share for file {} in warren {} for authenticated user {}", + response.share().path(), + response.share().warren_id(), + user.id(), + ); + } + + async fn auth_warren_shares_listed(&self, user: &User, response: &ListSharesResponse) { + tracing::debug!( + "[Notifier] Listed {} share(s) for file {} in warren {} for authenticated user {}", + response.shares().len(), + response.path(), + response.warren().id(), + user.id(), + ); + } + + async fn auth_warren_share_deleted(&self, user: &User, response: &DeleteShareResponse) { + tracing::debug!( + "[Notifier] Deleted share {} for file {} in warren {} for authenticated user {}", + response.share().id(), + response.share().path(), + response.share().warren_id(), + user.id(), + ); + } } impl OidcNotifier for NotifierDebugLogger { diff --git a/backend/src/lib/outbound/postgres/auth.rs b/backend/src/lib/outbound/postgres/auth.rs index 6e6f375..9be74d3 100644 --- a/backend/src/lib/outbound/postgres/auth.rs +++ b/backend/src/lib/outbound/postgres/auth.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use anyhow::{Context as _, anyhow, bail}; use argon2::{ Argon2, PasswordHash, PasswordVerifier as _, @@ -9,8 +7,7 @@ use argon2::{ }, }; use chrono::Utc; -use sqlx::{Acquire as _, PgConnection, PgPool}; -use tokio::task::JoinHandle; +use sqlx::{Acquire as _, PgConnection}; use uuid::Uuid; use crate::domain::warren::{ @@ -372,27 +369,7 @@ impl AuthRepository for Postgres { } impl Postgres { - pub(super) fn start_session_cleanup_task(pool: PgPool, interval: Duration) -> JoinHandle<()> { - tokio::spawn(async move { - loop { - { - let Ok(mut connection) = pool.acquire().await else { - break; - }; - - if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await { - tracing::debug!("Removed {count} expired auth session(s)"); - } - } - - tokio::time::sleep(interval).await; - } - - tracing::debug!("Session cleanup task stopped"); - }) - } - - async fn delete_expired_auth_sessions( + pub(super) async fn delete_expired_auth_sessions( connection: &mut PgConnection, ) -> Result { let delete_count = sqlx::query( @@ -916,7 +893,11 @@ impl Postgres { can_list_files = $3, can_read_files = $4, can_modify_files = $5, - can_delete_files = $6 + can_delete_files = $6, + can_list_shares = $7, + can_create_shares = $8, + can_modify_shares = $9, + can_delete_shares = $10 WHERE user_id = $1 AND warren_id = $2 @@ -930,6 +911,10 @@ impl Postgres { .bind(user_warren.can_read_files()) .bind(user_warren.can_modify_files()) .bind(user_warren.can_delete_files()) + .bind(user_warren.can_list_shares()) + .bind(user_warren.can_create_shares()) + .bind(user_warren.can_modify_shares()) + .bind(user_warren.can_delete_shares()) .fetch_one(connection) .await?; diff --git a/backend/src/lib/outbound/postgres/mod.rs b/backend/src/lib/outbound/postgres/mod.rs index 3e568a3..727b691 100644 --- a/backend/src/lib/outbound/postgres/mod.rs +++ b/backend/src/lib/outbound/postgres/mod.rs @@ -5,7 +5,9 @@ use sqlx::{ ConnectOptions as _, Connection as _, PgConnection, PgPool, postgres::{PgConnectOptions, PgPoolOptions}, }; +use tokio::task::JoinHandle; pub mod auth; +pub mod share; pub mod warrens; #[derive(Debug, Clone)] @@ -58,10 +60,34 @@ impl Postgres { sqlx::migrate!("./migrations").run(&pool).await?; // 3600 seconds = 1 hour - Self::start_session_cleanup_task(pool.clone(), Duration::from_secs(3600)); + Self::start_cleanup_tasks(pool.clone(), Duration::from_secs(3600)); Ok(Self { pool }) } + + pub(super) fn start_cleanup_tasks(pool: PgPool, interval: Duration) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + { + let Ok(mut connection) = pool.acquire().await else { + break; + }; + + if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await { + tracing::debug!("Removed {count} expired auth session(s)"); + } + + if let Ok(count) = Self::delete_expired_shares(&mut connection).await { + tracing::debug!("Deleted {count} expired share(s)"); + } + } + + tokio::time::sleep(interval).await; + } + + tracing::debug!("Session cleanup task stopped"); + }) + } } pub(super) fn is_not_found_error(err: &sqlx::Error) -> bool { diff --git a/backend/src/lib/outbound/postgres/share.rs b/backend/src/lib/outbound/postgres/share.rs new file mode 100644 index 0000000..9f7bb08 --- /dev/null +++ b/backend/src/lib/outbound/postgres/share.rs @@ -0,0 +1,285 @@ +use anyhow::anyhow; +use argon2::{ + Argon2, PasswordHash, PasswordVerifier as _, + password_hash::{PasswordHasher as _, SaltString, rand_core::OsRng}, +}; +use chrono::{NaiveDateTime, Utc}; +use sqlx::{Acquire as _, PgConnection}; +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::{ + file::{AbsoluteFilePathError, FilePath, FilePathError}, + share::{ + CreateShareRequest, DeleteShareRequest, GetShareRequest, ListSharesRequest, Share, + VerifySharePasswordError, VerifySharePasswordRequest, + }, + warren::HasWarrenId as _, +}; + +use super::{Postgres, is_not_found_error}; + +#[derive(sqlx::FromRow)] +struct ShareRow { + id: Uuid, + + creator_id: Uuid, + warren_id: Uuid, + + path: String, + + password_hash: Option, + + expires_at: Option, + + created_at: NaiveDateTime, +} + +#[derive(Debug, Error)] +enum TryFromShareRowError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl TryFrom for Share { + type Error = TryFromShareRowError; + + fn try_from(value: ShareRow) -> Result { + Ok(Share::new( + value.id, + value.creator_id, + value.warren_id, + FilePath::new(&value.path)?.try_into()?, + value.password_hash, + value + .expires_at + .map(|nt| nt.and_utc().timestamp_millis() as u64), + value.created_at.and_utc().timestamp_millis() as u64, + )) + } +} + +pub(super) async fn get_share( + connection: &mut PgConnection, + request: GetShareRequest, +) -> anyhow::Result { + let share_row: ShareRow = sqlx::query_as( + " + SELECT + id, + creator_id, + warren_id, + path, + password_hash, + expires_at, + created_at + FROM + shares + WHERE + id = $1 AND + (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + ", + ) + .bind(request.share_id()) + .fetch_one(connection) + .await?; + + Ok(Share::try_from(share_row)?) +} + +pub(super) async fn list_shares( + connection: &mut PgConnection, + request: ListSharesRequest, +) -> anyhow::Result> { + let share_rows: Vec = sqlx::query_as( + " + SELECT + id, + creator_id, + warren_id, + path, + password_hash, + expires_at, + created_at + FROM + shares + WHERE + warren_id = $1 AND + path = $2 + ORDER BY + created_at DESC + ", + ) + .bind(request.warren_id()) + .bind(request.path()) + .fetch_all(connection) + .await?; + + let shares = share_rows + .into_iter() + .map(Share::try_from) + .collect::, TryFromShareRowError>>()?; + + Ok(shares) +} + +pub(super) async fn create_share( + connection: &mut PgConnection, + request: CreateShareRequest, +) -> anyhow::Result { + let mut tx = connection.begin().await?; + + let password_hash = if let Some(password) = request.base().password() { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + Some( + argon2 + .hash_password(password.as_str().as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow!("Failed to hash file password: {e:?}"))?, + ) + } else { + None + }; + + let expires_at = if let Some(lifetime) = request.base().lifetime() { + Some(Utc::now().timestamp_millis() + i64::try_from(lifetime)?.saturating_mul(1000)) + } else { + None + }; + + let share: ShareRow = sqlx::query_as( + " + INSERT INTO shares ( + creator_id, + warren_id, + path, + password_hash, + expires_at + ) VALUES ( + $1, + $2, + $3, + $4, + TO_TIMESTAMP($5::double precision / 1000) + ) + RETURNING + * + ", + ) + .bind(request.creator_id()) + .bind(request.warren_id()) + .bind(request.base().path()) + .bind(password_hash) + .bind(expires_at) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Share::try_from(share)?) +} + +pub(super) async fn delete_share( + connection: &mut PgConnection, + request: DeleteShareRequest, +) -> anyhow::Result { + let mut tx = connection.begin().await?; + + let share_row: ShareRow = sqlx::query_as( + " + DELETE FROM + shares + WHERE + id = $1 + RETURNING + * + ", + ) + .bind(request.share_id()) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Share::try_from(share_row)?) +} + +pub(super) async fn verify_password( + connection: &mut PgConnection, + request: VerifySharePasswordRequest, +) -> Result { + let share_row: ShareRow = sqlx::query_as( + " + SELECT + * + FROM + shares + WHERE + id = $1 AND + (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + ", + ) + .bind(request.share_id()) + .fetch_one(connection) + .await + .map_err(|e| { + if is_not_found_error(&e) { + VerifySharePasswordError::NotFound + } else { + anyhow!(e).into() + } + })?; + + let share = Share::try_from(share_row).map_err(|e| anyhow!(e))?; + + match (request.password(), share.password_hash()) { + // If the share doesn't have a password hash or the request provided a password but the share doesn't have one we do + // not care + (None, None) | (Some(_), None) => Ok(share), + // If there is a password hash and the request provided a password we have to check if they + // match + (Some(password), Some(hash)) => { + let argon = Argon2::default(); + let hash = PasswordHash::new(hash) + .map_err(|e| VerifySharePasswordError::Unknown(anyhow!(e)))?; + + argon + .verify_password(password.as_str().as_bytes(), &hash) + .map_err(|e| match e { + argon2::password_hash::Error::Password => { + VerifySharePasswordError::IncorrectPassword + } + _ => VerifySharePasswordError::Unknown(anyhow!(e)), + })?; + + Ok(share) + } + // If the request didn't provide a password but the share has a password hash the access + // should be denied + (None, Some(_hash)) => Err(VerifySharePasswordError::IncorrectPassword), + } +} + +impl Postgres { + pub(super) async fn delete_expired_shares( + connection: &mut PgConnection, + ) -> Result { + let delete_count = sqlx::query( + " + DELETE FROM + shares + WHERE + expires_at <= CURRENT_TIMESTAMP + ", + ) + .execute(connection) + .await? + .rows_affected(); + + Ok(delete_count) + } +} diff --git a/backend/src/lib/outbound/postgres/warrens.rs b/backend/src/lib/outbound/postgres/warrens.rs index cb9fb48..2fc14bd 100644 --- a/backend/src/lib/outbound/postgres/warrens.rs +++ b/backend/src/lib/outbound/postgres/warrens.rs @@ -5,6 +5,12 @@ use uuid::Uuid; use crate::domain::warren::{ models::{ file::AbsoluteFilePath, + share::{ + CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError, + DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest, + ListSharesError, ListSharesRequest, ListSharesResponse, Share, + VerifySharePasswordError, VerifySharePasswordRequest, VerifySharePasswordResponse, + }, warren::{ CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest, EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, @@ -132,6 +138,86 @@ impl WarrenRepository for Postgres { Ok(warren) } + + async fn get_warren_share(&self, request: GetShareRequest) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + super::share::get_share(&mut connection, request) + .await + .map_err(Into::into) + } + + async fn create_warren_share( + &self, + request: CreateShareRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + super::share::create_share(&mut connection, request) + .await + .map(CreateShareResponse::new) + .map_err(Into::into) + } + + async fn list_warren_shares( + &self, + request: ListSharesRequest, + ) -> Result { + let warren = self.fetch_warren((&request).into()).await?; + + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let path = request.path().clone(); + + super::share::list_shares(&mut connection, request) + .await + .map(|shares| ListSharesResponse::new(warren, path, shares)) + .map_err(Into::into) + } + + async fn delete_warren_share( + &self, + request: DeleteShareRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + super::share::delete_share(&mut connection, request) + .await + .map(DeleteShareResponse::new) + .map_err(Into::into) + } + + async fn verify_warren_share_password( + &self, + request: VerifySharePasswordRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + super::share::verify_password(&mut connection, request) + .await + .map(VerifySharePasswordResponse::new) + .map_err(Into::into) + } } impl Postgres { diff --git a/frontend/bun.lock b/frontend/bun.lock index cc82c64..8b28170 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -11,12 +11,14 @@ "@nuxt/test-utils": "3.19.2", "@pinia/nuxt": "^0.11.1", "@tailwindcss/vite": "^4.1.11", + "@tanstack/vue-table": "^8.21.3", "@vee-validate/yup": "^4.15.1", "@vee-validate/zod": "^4.15.1", - "@vueuse/core": "^13.5.0", + "@vueuse/core": "^13.7.0", "byte-size": "^9.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs-nuxt": "2.1.11", "eslint": "^9.0.0", "lucide-vue-next": "^0.525.0", "nuxt": "^3.17.6", @@ -489,8 +491,12 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="], + "@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="], + "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww=="], "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], @@ -583,11 +589,11 @@ "@vue/shared": ["@vue/shared@3.5.17", "", {}, "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="], - "@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="], + "@vueuse/core": ["@vueuse/core@13.7.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.7.0", "@vueuse/shared": "13.7.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg=="], - "@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="], + "@vueuse/metadata": ["@vueuse/metadata@13.7.0", "", {}, "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg=="], - "@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="], + "@vueuse/shared": ["@vueuse/shared@13.7.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg=="], "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], @@ -813,6 +819,10 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], + + "dayjs-nuxt": ["dayjs-nuxt@2.1.11", "", { "dependencies": { "@nuxt/kit": "^3.7.4", "dayjs": "^1.11.10" } }, "sha512-KDDNiET7KAKf6yzL3RaPWq5aV7ql9QTt5fIDYv+4eOegDmnEQGjwkKYADDystsKtPjt7QZerpVbhC96o3BIyqQ=="], + "db0": ["db0@0.3.2", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], diff --git a/frontend/components/DirectoryBackEntry.vue b/frontend/components/DirectoryBackEntry.vue index 8a4f8b4..d0c6351 100644 --- a/frontend/components/DirectoryBackEntry.vue +++ b/frontend/components/DirectoryBackEntry.vue @@ -2,8 +2,9 @@ import type { DirectoryEntry } from '~/shared/types'; const { entry } = defineProps<{ entry: DirectoryEntry }>(); - -const warrenStore = useWarrenStore(); +const emit = defineEmits<{ + back: []; +}>(); const onDrop = onDirectoryEntryDrop(entry, true); @@ -12,7 +13,7 @@ const onDrop = onDirectoryEntryDrop(entry, true);