basic file sharing

This commit is contained in:
2025-08-29 15:32:23 +02:00
parent c8b52a5b3b
commit 284d805590
84 changed files with 3969 additions and 375 deletions

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE shares ALTER COLUMN password_hash DROP NOT NULL;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_shares_path ON shares(path);

View File

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

View File

@@ -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<Path> 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)]

View File

@@ -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::*;

View File

@@ -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<StatResponse> for File {
fn from(value: StatResponse) -> Self {
value.file
}
}

View File

@@ -1,5 +1,6 @@
pub mod auth_session;
pub mod file;
pub mod share;
pub mod user;
pub mod user_warren;
pub mod warren;

View File

@@ -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<String>,
expires_at: Option<u64>,
created_at: u64,
}
impl Share {
pub fn new(
id: Uuid,
creator_id: Uuid,
warren_id: Uuid,
path: AbsoluteFilePath,
password_hash: Option<String>,
expires_at: Option<u64>,
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<u64> {
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<Self, SharePasswordError> {
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
}
}

View File

@@ -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<SharePassword>,
}
impl ShareCatRequest {
pub fn new(share_id: Uuid, path: AbsoluteFilePath, password: Option<SharePassword>) -> 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<ShareCatResponse> 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),
}

View File

@@ -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<FetchWarrenRequest> 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<SharePassword>,
lifetime: Option<u64>,
}
impl CreateShareBaseRequest {
pub fn new(
warren_id: Uuid,
path: AbsoluteFilePath,
password: Option<SharePassword>,
lifetime: Option<u64>,
) -> 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<u64> {
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),
}

View File

@@ -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<DeleteShareResponse> 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),
}

View File

@@ -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<GetShareResponse> 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),
}

View File

@@ -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<FetchWarrenRequest> 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<Share>,
}
impl ListSharesResponse {
pub fn new(warren: Warren, path: AbsoluteFilePath, shares: Vec<Share>) -> Self {
Self {
warren,
path,
shares,
}
}
pub fn warren(&self) -> &Warren {
&self.warren
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
}
pub fn shares(&self) -> &Vec<Share> {
&self.shares
}
}
#[derive(Debug, Error)]
pub enum ListSharesError {
#[error(transparent)]
Stat(#[from] StatError),
#[error(transparent)]
FetchWarren(#[from] FetchWarrenError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -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<SharePassword>,
}
impl ShareLsRequest {
pub fn new(share_id: Uuid, path: AbsoluteFilePath, password: Option<SharePassword>) -> 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),
}

View File

@@ -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::*;

View File

@@ -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<SharePassword>,
}
impl VerifySharePasswordRequest {
pub fn new(share_id: Uuid, password: Option<SharePassword>) -> 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<VerifySharePasswordResponse> 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),
}

View File

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

View File

@@ -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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> 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<FetchWarrenRequest> for &WarrenCpRequest {
fn into(self) -> FetchWarrenRequest {
FetchWarrenRequest::new(self.warren_id)

View File

@@ -42,6 +42,24 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static {
fn record_warren_cp_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_cp_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_get_share_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_get_share_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_creation_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_creation_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_list_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_list_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_deletion_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_ls_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_ls_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_cat_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_share_cat_failure(&self) -> impl Future<Output = ()> + 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<Output = ()> + Send;
fn record_cp_failure(&self) -> impl Future<Output = ()> + Send;
fn record_stat_success(&self) -> impl Future<Output = ()> + Send;
fn record_stat_failure(&self) -> impl Future<Output = ()> + 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<Output = ()> + Send;
fn record_auth_warren_cp_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_creation_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_creation_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_list_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_list_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_deletion_failure(&self) -> impl Future<Output = ()> + Send;
}

View File

@@ -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<Output = Result<WarrenCpResponse, WarrenCpError>> + Send;
fn warren_get_share(
&self,
request: GetShareRequest,
) -> impl Future<Output = Result<GetShareResponse, GetShareError>> + Send;
fn warren_create_share(
&self,
request: CreateShareRequest,
) -> impl Future<Output = Result<CreateShareResponse, CreateShareError>> + Send;
fn warren_list_shares(
&self,
request: ListSharesRequest,
) -> impl Future<Output = Result<ListSharesResponse, ListSharesError>> + Send;
fn warren_delete_share(
&self,
request: DeleteShareRequest,
) -> impl Future<Output = Result<DeleteShareResponse, DeleteShareError>> + Send;
fn warren_share_ls(
&self,
request: ShareLsRequest,
) -> impl Future<Output = Result<ShareLsResponse, ShareLsError>> + Send;
fn warren_share_cat(
&self,
request: ShareCatRequest,
) -> impl Future<Output = Result<ShareCatResponse, ShareCatError>> + Send;
}
pub trait FileSystemService: Clone + Send + Sync + 'static {
@@ -121,6 +152,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
) -> impl Future<Output = Result<SaveResponse, SaveError>> + Send;
fn touch(&self, request: TouchRequest) -> impl Future<Output = Result<(), TouchError>> + Send;
fn cp(&self, request: CpRequest) -> impl Future<Output = Result<CpResponse, CpError>> + Send;
fn stat(
&self,
request: StatRequest,
) -> impl Future<Output = Result<StatResponse, StatError>> + Send;
}
pub trait AuthService: Clone + Send + Sync + 'static {
@@ -273,4 +308,20 @@ pub trait AuthService: Clone + Send + Sync + 'static {
request: AuthRequest<WarrenCpRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<WarrenCpResponse, AuthError<WarrenCpError>>> + Send;
fn auth_warren_create_share<WS: WarrenService>(
&self,
request: AuthRequest<CreateShareBaseRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<CreateShareResponse, AuthError<CreateShareError>>> + Send;
fn auth_warren_list_shares<WS: WarrenService>(
&self,
request: AuthRequest<ListSharesRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<ListSharesResponse, AuthError<ListSharesError>>> + Send;
fn auth_warren_delete_share<WS: WarrenService>(
&self,
request: AuthRequest<DeleteShareRequest>,
warren_service: &WS,
) -> impl Future<Output = Result<DeleteShareResponse, AuthError<DeleteShareError>>> + Send;
}

View File

@@ -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<Output = ()> + Send;
fn warren_cp(&self, response: &WarrenCpResponse) -> impl Future<Output = ()> + Send;
fn got_warren_share(&self, response: &GetShareResponse) -> impl Future<Output = ()> + Send;
fn warren_share_created(
&self,
response: &CreateShareResponse,
) -> impl Future<Output = ()> + Send;
fn warren_shares_listed(
&self,
response: &ListSharesResponse,
) -> impl Future<Output = ()> + Send;
fn warren_share_deleted(
&self,
response: &DeleteShareResponse,
) -> impl Future<Output = ()> + Send;
fn warren_share_ls(&self, response: &ShareLsResponse) -> impl Future<Output = ()> + Send;
fn warren_share_cat(&self, response: &ShareCatResponse) -> impl Future<Output = ()> + 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<Output = ()> + Send;
fn stat(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + 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<Output = ()> + Send;
fn auth_warren_share_created(
&self,
user: &User,
response: &CreateShareResponse,
) -> impl Future<Output = ()> + Send;
fn auth_warren_shares_listed(
&self,
user: &User,
response: &ListSharesResponse,
) -> impl Future<Output = ()> + Send;
fn auth_warren_share_deleted(
&self,
user: &User,
response: &DeleteShareResponse,
) -> impl Future<Output = ()> + Send;
}

View File

@@ -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<Output = Result<Warren, FetchWarrenError>> + Send;
fn get_warren_share(
&self,
request: GetShareRequest,
) -> impl Future<Output = Result<Share, GetShareError>> + Send;
fn create_warren_share(
&self,
request: CreateShareRequest,
) -> impl Future<Output = Result<CreateShareResponse, CreateShareError>> + Send;
fn list_warren_shares(
&self,
request: ListSharesRequest,
) -> impl Future<Output = Result<ListSharesResponse, ListSharesError>> + Send;
fn delete_warren_share(
&self,
request: DeleteShareRequest,
) -> impl Future<Output = Result<DeleteShareResponse, DeleteShareError>> + Send;
fn verify_warren_share_password(
&self,
request: VerifySharePasswordRequest,
) -> impl Future<Output = Result<VerifySharePasswordResponse, VerifySharePasswordError>> + Send;
}
pub trait FileSystemRepository: Clone + Send + Sync + 'static {
@@ -78,6 +105,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
) -> impl Future<Output = Result<SaveResponse, SaveError>> + Send;
fn touch(&self, request: TouchRequest) -> impl Future<Output = Result<(), TouchError>> + Send;
fn cp(&self, request: CpRequest) -> impl Future<Output = Result<CpResponse, CpError>> + Send;
fn stat(
&self,
request: StatRequest,
) -> impl Future<Output = Result<StatResponse, StatError>> + Send;
}
pub trait AuthRepository: Clone + Send + Sync + 'static {

View File

@@ -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<WarrenLsRequest>,
warren_service: &WS,
) -> Result<WarrenLsResponse, AuthError<WarrenLsError>> {
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<WarrenCatRequest>,
warren_service: &WS,
) -> Result<FileStream, AuthError<WarrenCatError>> {
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<WarrenMkdirRequest>,
warren_service: &WS,
) -> Result<WarrenMkdirResponse, AuthError<WarrenMkdirError>> {
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<WarrenRmRequest>,
warren_service: &WS,
) -> Result<WarrenRmResponse, AuthError<WarrenRmError>> {
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<WarrenMvRequest>,
warren_service: &WS,
) -> Result<WarrenMvResponse, AuthError<WarrenMvError>> {
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<WarrenSaveRequest<'_>>,
warren_service: &WS,
) -> Result<WarrenSaveResponse, AuthError<WarrenSaveError>> {
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<WarrenTouchRequest>,
warren_service: &WS,
) -> Result<WarrenTouchResponse, AuthError<WarrenTouchError>> {
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<WarrenCpRequest>,
warren_service: &WS,
) -> Result<WarrenCpResponse, AuthError<WarrenCpError>> {
let session_response = self.fetch_auth_session((&request).into()).await?;
let request = request.into_value();
let user_warren = self
.repository
.fetch_user_warren(FetchUserWarrenRequest::new(
session_response.user().id().clone(),
request.warren_id().clone(),
))
.await?;
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<WS: WarrenService>(
&self,
request: AuthRequest<CreateShareBaseRequest>,
warren_service: &WS,
) -> Result<CreateShareResponse, AuthError<CreateShareError>> {
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<WS: WarrenService>(
&self,
request: AuthRequest<ListSharesRequest>,
warren_service: &WS,
) -> Result<ListSharesResponse, AuthError<ListSharesError>> {
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<WS: WarrenService>(
&self,
request: AuthRequest<DeleteShareRequest>,
warren_service: &WS,
) -> Result<DeleteShareResponse, AuthError<DeleteShareError>> {
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<R, M, N, OIDC> Service<R, M, N, OIDC>
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<T, E>(
&self,
request: AuthRequest<T>,
) -> Result<(T, User, UserWarren), AuthError<E>>
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))
}
}

View File

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

View File

@@ -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<GetShareResponse, GetShareError> {
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<CreateShareResponse, CreateShareError> {
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<ListSharesResponse, ListSharesError> {
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<DeleteShareResponse, DeleteShareError> {
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<ShareLsResponse, ShareLsError> {
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<ShareCatResponse, ShareCatError> {
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)
}
}

View File

@@ -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<T: std::error::Error> From<AuthError<T>> for ApiError {
impl From<CreateUserError> 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<GetOidcRedirectError> 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<GetShareError> 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<VerifySharePasswordError> 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<ShareLsError> 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<ShareCatError> 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(),
}
}
}

View File

@@ -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,
))
}
}

View File

@@ -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,
))
}
}

View File

@@ -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<SharePassword>);
impl SharePasswordHeader {
const NAME: &str = "X-Share-Password";
}
impl<S> FromRequestParts<S> for SharePasswordHeader
where
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// 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()))
}
}
}

View File

@@ -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<UserWarren> for UserWarrenData {
@@ -50,23 +56,10 @@ impl From<UserWarren> 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<Warren> 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<Warren> 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<u64>,
created_at: u64,
}
impl<T> From<T> for ShareData
where
T: Into<Share>,
{
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(),
}
}
}

View File

@@ -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<ParseShareCatHttpRequestError> 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<SharePassword>,
) -> Result<ShareCatRequest, ParseShareCatHttpRequestError> {
let path = FilePath::new(&self.path)?.try_into()?;
Ok(ShareCatRequest::new(self.share_id, path, password))
}
}
pub async fn cat_share<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SharePasswordHeader(password): SharePasswordHeader,
Query(request): Query<ShareCatHttpRequestBody>,
) -> Result<Body, ApiError> {
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)
}

View File

@@ -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<u64>,
password: Option<String>,
}
#[derive(Debug, Error)]
enum ParseCreateShareHttpRequestError {
#[error(transparent)]
FilePath(#[from] FilePathError),
#[error(transparent)]
AbsoluteFilePath(#[from] AbsoluteFilePathError),
#[error(transparent)]
SharePassword(#[from] SharePasswordError),
}
impl From<ParseCreateShareHttpRequestError> for ApiError {
fn from(e: ParseCreateShareHttpRequestError) -> Self {
ApiError::BadRequest(e.to_string())
}
}
impl CreateShareHttpRequestBody {
fn try_into_domain(self) -> Result<CreateShareBaseRequest, ParseCreateShareHttpRequestError> {
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<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Json(request): Json<CreateShareHttpRequestBody>,
) -> Result<ApiSuccess<ShareData>, 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)
}

View File

@@ -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<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Json(request): Json<DeleteShareHttpRequestBody>,
) -> Result<ApiSuccess<ShareData>, 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)
}

View File

@@ -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<GetShareResponse> for GetShareHttpResponseBody {
fn from(value: GetShareResponse) -> Self {
Self {
share: value.share().clone().into(),
file: value.file().into(),
}
}
}
pub async fn get_share<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<GetShareHttpRequestBody>,
) -> Result<ApiSuccess<GetShareHttpResponseBody>, 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)
}

View File

@@ -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<ParseListSharesHttpRequestError> for ApiError {
fn from(e: ParseListSharesHttpRequestError) -> Self {
ApiError::BadRequest(e.to_string())
}
}
impl ListSharesHttpRequestBody {
fn try_into_domain(self) -> Result<ListSharesRequest, ParseListSharesHttpRequestError> {
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<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
SessionIdHeader(session): SessionIdHeader,
Json(request): Json<ListSharesHttpRequestBody>,
) -> Result<ApiSuccess<Vec<ShareData>>, 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)
}

View File

@@ -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<ParseShareLsHttpRequestError> 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<String>,
}
impl LsShareHttpRequestBody {
fn try_into_domain(self) -> Result<ShareLsRequest, ParseShareLsHttpRequestError> {
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<WarrenFileElement>,
parent: Option<WarrenFileElement>,
}
impl From<ShareLsResponse> 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<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<LsShareHttpRequestBody>,
) -> Result<ApiSuccess<ShareLsResponseData>, 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)
}

View File

@@ -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<String>,
created_at: Option<u64>,
}
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<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new()
.route("/", get(list_warrens))
@@ -46,4 +82,10 @@ pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
)
.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))
}

View File

@@ -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<Vec<u8>> for FetchWarrenFileHttpResponseBody {
fn from(value: Vec<u8>) -> Self {
Self {
contents: base64::prelude::BASE64_STANDARD.encode(&value),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct WarrenCatHttpRequestBody {

View File

@@ -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<ParseWarrenLsHttpRequestError> for ApiError {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WarrenFileElement {
name: String,
file_type: FileType,
mime_type: Option<String>,
created_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListWarrenFilesResponseData {
@@ -81,17 +71,6 @@ pub struct ListWarrenFilesResponseData {
parent: Option<WarrenFileElement>,
}
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<WarrenLsResponse> for ListWarrenFilesResponseData {
fn from(value: WarrenLsResponse) -> Self {
Self {

View File

@@ -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<File> {
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<StatResponse, StatError> {
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<P>(path: P) -> Option<u64>
where
P: rustix::path::Arg,
{
get_statx(path)
.ok()
.map(|statx| statx.stx_btime.tv_sec as u64)
}
fn get_statx<P>(path: P) -> rustix::io::Result<Statx>
where
P: rustix::path::Arg,
{
@@ -349,6 +409,4 @@ where
rustix::fs::StatxFlags::BTIME,
)
}
.ok()
.map(|statx| statx.stx_btime.tv_sec as u64)
}

View File

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

View File

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

View File

@@ -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<u64, sqlx::Error> {
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?;

View File

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

View File

@@ -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<String>,
expires_at: Option<NaiveDateTime>,
created_at: NaiveDateTime,
}
#[derive(Debug, Error)]
enum TryFromShareRowError {
#[error(transparent)]
FilePath(#[from] FilePathError),
#[error(transparent)]
AbsoluteFilePath(#[from] AbsoluteFilePathError),
}
impl TryFrom<ShareRow> for Share {
type Error = TryFromShareRowError;
fn try_from(value: ShareRow) -> Result<Self, Self::Error> {
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<Share> {
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<Vec<Share>> {
let share_rows: Vec<ShareRow> = 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::<Result<Vec<Share>, TryFromShareRowError>>()?;
Ok(shares)
}
pub(super) async fn create_share(
connection: &mut PgConnection,
request: CreateShareRequest,
) -> anyhow::Result<Share> {
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<Share> {
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<Share, VerifySharePasswordError> {
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<u64, sqlx::Error> {
let delete_count = sqlx::query(
"
DELETE FROM
shares
WHERE
expires_at <= CURRENT_TIMESTAMP
",
)
.execute(connection)
.await?
.rows_affected();
Ok(delete_count)
}
}

View File

@@ -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<Share, GetShareError> {
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<CreateShareResponse, CreateShareError> {
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<ListSharesResponse, ListSharesError> {
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<DeleteShareResponse, DeleteShareError> {
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<VerifySharePasswordResponse, VerifySharePasswordError> {
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 {

View File

@@ -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=="],

View File

@@ -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);
</script>
@@ -12,7 +13,7 @@ const onDrop = onDirectoryEntryDrop(entry, true);
<button
class="bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none"
@contextmenu.prevent
@click="() => warrenStore.backCurrentPath()"
@click="() => emit('back')"
@drop="onDrop"
>
<div class="flex flex-row items-center">

View File

@@ -6,11 +6,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from '@/components/ui/context-menu';
import {
deleteWarrenDirectory,
deleteWarrenFile,
fetchFile,
} from '~/lib/api/warrens';
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
import type { DirectoryEntry } from '#shared/types';
import { toast } from 'vue-sonner';
@@ -23,6 +19,11 @@ const { entry, disabled } = defineProps<{
disabled: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-download': [entry: DirectoryEntry];
}>();
const deleting = ref(false);
const isCopied = computed(
() =>
@@ -63,30 +64,7 @@ async function openRenameDialog() {
}
async function onClick() {
if (warrenStore.loading || warrenStore.current == null) {
return;
}
if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
emit('entry-click', entry);
}
function onDragStart(e: DragEvent) {
@@ -112,38 +90,22 @@ function onCopy() {
);
}
async function onDownload() {
if (warrenStore.current == null) {
return;
}
function onShare() {
useShareDialog().openDialog(entry);
}
if (entry.fileType !== 'file') {
toast.warning('Download', {
description: 'Directory downloads are not supported yet',
});
return;
}
const anchor = document.createElement('a');
anchor.download = entry.name;
anchor.href = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
);
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.click();
function onDownload() {
emit('entry-download', entry);
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger>
<ContextMenuTrigger class="flex sm:w-52">
<button
:disabled="warrenStore.loading || disabled"
:class="[
'bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
isCopied && 'border-primary/50 border',
]"
draggable="true"
@@ -151,38 +113,7 @@ async function onDownload() {
@drop="onDrop"
@click="onClick"
>
<div class="flex flex-row items-center">
<Icon
v-if="
entry.fileType !== 'file' ||
entry.mimeType == null ||
!entry.mimeType.startsWith('image/')
"
class="size-6"
:name="
entry.fileType === 'file'
? getFileIcon(entry.mimeType)
: 'lucide:folder'
"
/>
<object
v-else
:type="entry.mimeType"
class="size-6 object-cover"
width="24"
height="24"
:data="
getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current!.path, entry.name)}`
)
"
>
<Icon
class="size-6"
:name="getFileIcon(entry.mimeType)"
/>
</object>
</div>
<DirectoryEntryIcon :entry />
<div
class="flex w-full flex-col items-start justify-stretch gap-0 overflow-hidden text-left leading-6"
@@ -198,11 +129,17 @@ async function onDownload() {
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem @select="openRenameDialog">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="openRenameDialog"
>
<Icon name="lucide:pencil" />
Rename
</ContextMenuItem>
<ContextMenuItem @select="onCopy">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="onCopy"
>
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
@@ -213,15 +150,28 @@ async function onDownload() {
<Icon name="lucide:download" />
Download
</ContextMenuItem>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="onShare"
>
<Icon name="lucide:share" />
Share
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator
:class="[warrenStore.current == null && 'hidden']"
/>
<ContextMenuItem @select="() => submitDelete(false)">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="() => submitDelete(false)"
>
<Icon name="lucide:trash-2" />
Delete
</ContextMenuItem>
<ContextMenuItem
v-if="entry.fileType === 'directory'"
:class="[warrenStore.current == null && 'hidden']"
@select="() => submitDelete(true)"
>
<Icon

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import type { DirectoryEntry } from '~/shared/types';
const { entry } = defineProps<{
entry: DirectoryEntry;
}>();
const route = useRoute();
const warrenStore = useWarrenStore();
</script>
<template>
<div class="flex flex-row items-center">
<Icon
v-if="
entry.fileType !== 'file' ||
entry.mimeType == null ||
!entry.mimeType.startsWith('image/')
"
class="size-6"
:name="
entry.fileType === 'file'
? getFileIcon(entry.mimeType)
: 'lucide:folder'
"
/>
<object
v-else-if="warrenStore.current != null"
:type="entry.mimeType"
class="size-6 object-cover"
width="24"
height="24"
:data="
route.meta.layout === 'share'
? getApiUrl(
`warrens/files/cat_share?shareId=${route.query.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
: getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
"
>
<Icon class="size-6" :name="getFileIcon(entry.mimeType)" />
</object>
<Icon v-else class="size-6" :name="getFileIcon(entry.mimeType)" />
</div>
</template>

View File

@@ -2,10 +2,22 @@
import { ScrollArea } from '@/components/ui/scroll-area';
import type { DirectoryEntry } from '#shared/types';
const { entries, parent, isOverDropZone } = defineProps<{
const {
entries,
parent,
isOverDropZone,
disableEntries = false,
} = defineProps<{
entries: DirectoryEntry[];
parent: DirectoryEntry | null;
isOverDropZone?: boolean;
disableEntries?: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-download': [entry: DirectoryEntry];
back: [];
}>();
const { isLoading } = useLoadingIndicator();
@@ -13,23 +25,39 @@ const { isLoading } = useLoadingIndicator();
const sortedEntries = computed(() =>
entries.toSorted((a, b) => a.name.localeCompare(b.name))
);
function onEntryClicked(entry: DirectoryEntry) {
emit('entry-click', entry);
}
function onEntryDownload(entry: DirectoryEntry) {
emit('entry-download', entry);
}
</script>
<template>
<ScrollArea class="h-full w-full">
<ScrollArea class="flex h-full w-full flex-col overflow-hidden">
<div
v-if="isOverDropZone"
class="bg-background/50 pointer-events-none absolute flex h-full w-full items-center justify-center"
>
<Icon class="size-16 animate-pulse" name="lucide:upload" />
</div>
<div class="flex flex-row flex-wrap gap-2">
<DirectoryBackEntry v-if="parent != null" :entry="parent" />
<div
class="flex w-full flex-col gap-2 overflow-hidden sm:flex-row sm:flex-wrap"
>
<DirectoryBackEntry
v-if="parent != null"
:entry="parent"
@back="() => emit('back')"
/>
<DirectoryEntry
v-for="entry in sortedEntries"
:key="entry.name"
:entry="entry"
:disabled="isLoading"
:disabled="isLoading || disableEntries"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
/>
</div>
</ScrollArea>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'vue-sonner';
import { deleteShare } from '~/lib/api/shares';
import type { Share } from '~/shared/types/shares';
const { shares } = defineProps<{
shares: Share[];
}>();
function onCopyClicked(share: Share) {
const link = getShareLink(share);
if (copyToClipboard(link)) {
toast.success('Share', {
description: 'Copied the link to the clipboard',
});
} else {
console.log(`Here's the link to ${share.path}: ${link}`);
toast.error('Share', {
description: `Failed to copy the link to the clipboard. Logged it to the console instead.`,
});
}
}
async function onDeleteClicked(share: Share) {
const result = await deleteShare(share.warrenId, share.id);
if (result.success) {
refreshNuxtData('current-file-shares');
toast.success('Share', {
description: `Successfully deleted the share for ${result.share.path}`,
});
} else {
toast.error('Share', {
description: 'Failed to delete share',
});
}
}
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">ID</TableHead>
<TableHead>Password</TableHead>
<TableHead>Expiration</TableHead>
<TableHead>Created</TableHead>
<TableHead class="w-0 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="share in shares" :key="share.id">
<TableCell class="font-medium" :title="share.id">
<span class="hidden sm:block">
{{ share.id }}
</span>
<span class="block sm:hidden"
>{{ share.id.slice(0, 3) }}...{{ share.id.slice(-3) }}
</span>
</TableCell>
<TableCell>{{ share.password ? 'Yes' : 'No' }}</TableCell>
<TableCell>{{
share.expiresAt == null
? 'Never'
: $dayjs(share.expiresAt).format('MMM D, YYYY HH:mm')
}}</TableCell>
<TableCell>{{
$dayjs(share.createdAt).format('MMM D, YYYY HH:mm')
}}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
@click="() => onCopyClicked(share)"
><Icon name="lucide:copy"
/></Button>
<Button
variant="ghost"
size="icon"
@click="() => onDeleteClicked(share)"
><Icon name="lucide:trash-2"
/></Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { useShareDialog } from '@/stores';
import { toTypedSchema } from '@vee-validate/yup';
import { useForm } from 'vee-validate';
import { toast } from 'vue-sonner';
import { createShare, listShares } from '~/lib/api/shares';
import { shareSchema } from '~/lib/schemas/share';
import type { Share } from '~/shared/types/shares';
const warrenStore = useWarrenStore();
const dialog = useShareDialog();
const newScreen = ref(false);
const existingShares = ref<Share[]>([]);
useAsyncData('current-file-shares', async () => {
if (warrenStore.current == null || dialog.target == null) {
return [];
}
const result = await listShares(
warrenStore.current.warrenId,
joinPaths(warrenStore.current.path, dialog.target.name)
);
if (!result.success) {
return [];
}
if (result.shares.length < 1) {
newScreen.value = true;
} else {
newScreen.value = false;
}
existingShares.value = result.shares;
});
dialog.$subscribe(async (_, value) => {
if (value.target !== null) {
await refreshNuxtData('current-file-shares');
}
});
function onOpenChange() {
dialog.reset();
}
function onCancel() {
if (newScreen.value && existingShares.value.length > 0) {
newScreen.value = false;
} else {
dialog.reset();
}
}
const form = useForm({
validationSchema: toTypedSchema(shareSchema),
});
const onSubmit = form.handleSubmit(async (values) => {
if (dialog.target == null || warrenStore.current == null) {
return;
}
const result = await createShare(
warrenStore.current.warrenId,
joinPaths(warrenStore.current.path, dialog.target.name),
values.password ?? null,
values.lifetime ?? null
);
if (result.success) {
toast.success('Share', {
description: `Successfully created a share for ${dialog.target.name}`,
});
await refreshNuxtData('current-file-shares');
newScreen.value = false;
} else {
toast.error('Share', {
description: `Failed to create share`,
});
}
});
</script>
<template>
<Dialog :open="dialog.target != null" @update:open="onOpenChange">
<DialogTrigger as-child>
<slot />
</DialogTrigger>
<DialogContent
v-if="dialog.target != null"
class="w-full !max-w-[calc(100vw-8px)] sm:!max-w-[min(98vw,1000px)]"
>
<DialogHeader class="overflow-hidden">
<DialogTitle class="truncate"
>Share {{ dialog.target.name }}</DialogTitle
>
<DialogDescription
>Create a shareable link to this
{{ dialog.target.fileType }}</DialogDescription
>
</DialogHeader>
<SharesTable v-if="!newScreen" :shares="existingShares" />
<form
v-else
id="share-form"
class="grid gap-4"
@submit.prevent="onSubmit"
>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="password"
type="password"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
<FormDescription>
<span
v-if="
form.values.password == null ||
form.values.password.length < 1
"
>
The share will not require a password
</span>
<span v-else>
The share will require the specified password
</span>
</FormDescription>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="lifetime">
<FormItem>
<FormLabel>Lifetime (seconds)</FormLabel>
<FormControl>
<div
class="flex w-full flex-row justify-between gap-1"
>
<Input
v-bind="componentField"
id="lifetime"
type="number"
class="w-full max-w-full min-w-0"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
</FormControl>
<FormMessage />
<FormDescription>
<span
v-if="
form.values.lifetime != null &&
form.values.lifetime > 0
"
class="w-full"
>
The share will expire in
{{
$dayjs
.duration({
seconds: form.values.lifetime,
})
.humanize()
}}
</span>
<span v-else> The share will be permanent </span>
</FormDescription>
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="ghost" @click="onCancel">Cancel</Button>
<Button v-if="newScreen" type="submit" form="share-form"
>Share</Button
>
<Button v-else @click="() => (newScreen = true)">New</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -13,19 +13,37 @@ import type { UserWarren } from '~/shared/types/warrens';
const props = defineProps<{
userWarren: UserWarren;
}>();
let realUserWarrenState: UserWarren = JSON.parse(
JSON.stringify(props.userWarren)
);
const userWarren = props.userWarren;
const adminStore = useAdminStore();
const updatePermissionsDebounced = useDebounceFn(
async (userWarren: UserWarren) => {
const result = await editUserWarren(userWarren);
async (uw: UserWarren) => {
const result = await editUserWarren(uw);
if (result.success) {
for (const [key, value] of Object.entries(result.data)) {
if (key in userWarren) {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
userWarren[key] = value;
}
}
realUserWarrenState = JSON.parse(JSON.stringify(result.data));
toast.success('Permissions', {
description: `Successfully updated the user's permissions`,
});
} else {
for (const [key, value] of Object.entries(realUserWarrenState)) {
if (key in userWarren) {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
userWarren[key] = value;
}
}
userWarren.canCreateShares = realUserWarrenState.canCreateShares;
toast.error('Permissions', {
description: `Failed to update the user's permissions`,
});

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"

View File

@@ -0,0 +1,10 @@
import type { Updater } from "@tanstack/vue-table"
import type { Ref } from "vue"
import { isFunction } from "@tanstack/vue-table"
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}

View File

@@ -27,16 +27,7 @@ export function setAuthSession(value: {
}) {
useAuthSession().value = value;
let cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
const config = useRuntimeConfig().public;
console.log('config', config);
const cookieDomain = config.authCookieDomain;
if (cookieDomain != null && cookieDomain.length > 0) {
cookie += ` domain=${cookieDomain}`;
}
console.log(cookie);
const cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
document.cookie = cookie;
}

View File

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

View File

@@ -0,0 +1,8 @@
<script lang="ts" setup></script>
<template>
<main class="flex h-full w-full items-center justify-center">
<ImageViewer />
<slot />
</main>
</template>

176
frontend/lib/api/shares.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { ApiResponse } from '~/shared/types/api';
import type { Share } from '~/shared/types/shares';
import { getApiHeaders } from '.';
import type { DirectoryEntry } from '~/shared/types';
export async function getShare(
shareId: string
): Promise<
{ success: true; share: Share; file: DirectoryEntry } | { success: false }
> {
const { data } = await useFetch<
ApiResponse<{ share: Share; file: DirectoryEntry }>
>(getApiUrl('warrens/files/get_share'), {
method: 'POST',
headers: getApiHeaders(false),
body: JSON.stringify({
shareId: shareId,
}),
});
if (data.value == null) {
return {
success: false,
};
}
const { share, file } = data.value.data;
return {
success: true,
share,
file,
};
}
export async function createShare(
warrenId: string,
path: string,
password: string | null,
lifetime: number | null
): Promise<{ success: true; share: Share } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share>>(
getApiUrl('warrens/files/create_share'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
path: path,
lifetime: lifetime,
password: password,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, share: data.value.data };
}
export async function listShares(
warrenId: string,
path: string
): Promise<{ success: true; shares: Share[] } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share[]>>(
getApiUrl('warrens/files/list_shares'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
path: path,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, shares: data.value.data };
}
export async function deleteShare(
warrenId: string,
shareId: string
): Promise<{ success: true; share: Share } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share>>(
getApiUrl('warrens/files/delete_share'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
shareId: shareId,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, share: data.value.data };
}
export async function listShareFiles(
shareId: string,
path: string,
password: string | null
): Promise<
| { success: true; files: DirectoryEntry[]; parent: DirectoryEntry | null }
| { success: false }
> {
const { data } = await useFetch<
ApiResponse<{ files: DirectoryEntry[]; parent: DirectoryEntry | null }>
>(getApiUrl('warrens/files/ls_share'), {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
shareId: shareId,
path: path,
password: password,
}),
});
if (data.value == null) {
return {
success: false,
};
}
const { files, parent } = data.value.data;
return { success: true, files, parent };
}
export async function fetchShareFile(
shareId: string,
path: string,
password: string | null
): Promise<{ success: true; data: Blob } | { success: false }> {
const { data } = await useFetch<Blob>(
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&path=${path}`),
{
method: 'GET',
headers:
password != null
? {
'X-Share-Password': password,
}
: {},
responseType: 'blob',
cache: 'default',
}
);
if (data.value == null) {
return {
success: false,
};
}
return {
success: true,
data: data.value,
};
}

View File

@@ -0,0 +1,12 @@
import { number, object, string } from 'yup';
export const shareSchema = object({
password: string()
.trim()
.transform((s: string) => (s.length > 0 ? s : undefined))
.optional(),
lifetime: number()
.positive()
.transform((n) => (isNaN(n) ? undefined : n))
.optional(),
});

View File

@@ -14,8 +14,13 @@ export default defineNuxtConfig({
'shadcn-nuxt',
'@nuxtjs/color-mode',
'@pinia/nuxt',
'dayjs-nuxt',
],
dayjs: {
plugins: ['duration', 'relativeTime'],
},
css: ['~/assets/css/tailwind.css', '~/assets/css/sonner.css'],
vite: {

View File

@@ -18,12 +18,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",

View File

@@ -33,7 +33,6 @@ if (
route.query.state &&
typeof route.query.state === 'string'
) {
console.log('SEND');
loggingIn.value = true;
const { success } = await oidcLoginUser(
route.query.code,

237
frontend/pages/share.vue Normal file
View File

@@ -0,0 +1,237 @@
<script lang="ts" setup>
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
import type { DirectoryEntry } from '~/shared/types';
import type { Share } from '~/shared/types/shares';
definePageMeta({
layout: 'share',
});
const warrenStore = useWarrenStore();
const route = useRoute();
const share = await getShareFromQuery();
const entries = ref<DirectoryEntry[] | null>(null);
const parent = ref<DirectoryEntry | null>(null);
const password = ref<string>('');
const loading = ref<boolean>(false);
if (share != null) {
warrenStore.setCurrentWarren(share.data.warrenId, '/');
if (!share.data.password) {
await loadFiles();
}
}
async function getShareFromQuery(): Promise<{
data: Share;
file: DirectoryEntry;
} | null> {
const shareId = route.query.id;
if (shareId == null || typeof shareId !== 'string') {
return null;
}
const result = await getShare(shareId);
if (!result.success) {
return null;
}
return { data: result.share, file: result.file };
}
async function submitPassword() {
loadFiles();
}
async function loadFiles() {
if (loading.value || share == null || warrenStore.current == null) {
return;
}
if (share.file.fileType !== 'directory') {
return;
}
loading.value = true;
const result = await listShareFiles(
share.data.id,
warrenStore.current.path,
password.value.length > 0 ? password.value : null
);
if (result.success) {
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
if (share.data.expiresAt != null) {
const dayjs = useDayjs();
const diff = dayjs(share.data.expiresAt).diff(dayjs()) / 1000;
cookie += `Max-Age=${diff};`;
}
document.cookie = cookie;
entries.value = result.files;
parent.value = result.parent;
}
loading.value = false;
}
async function onEntryClicked(entry: DirectoryEntry) {
if (warrenStore.current == null) {
return;
}
const entryPath = joinPaths(warrenStore.current.path, entry.name);
if (entry.fileType === 'directory') {
warrenStore.setCurrentWarrenPath(entryPath);
await loadFiles();
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchShareFile(
share!.data.id,
entryPath,
password.value.length > 0 ? password.value : null
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
}
async function onBack() {
if (warrenStore.backCurrentPath()) {
await loadFiles();
}
}
function onDowloadClicked() {
if (share == null) {
return;
}
downloadFile(
share.file.name,
getApiUrl(`warrens/files/cat_share?shareId=${share.data.id}&path=/`)
);
}
function onEntryDownload(entry: DirectoryEntry) {
if (share == null || warrenStore.current == null) {
return;
}
downloadFile(
entry.name,
getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
);
}
</script>
<template>
<div
v-if="share != null"
class="flex h-full w-full items-center justify-center px-2"
>
<div
:class="[
'w-full rounded-lg border transition-all',
entries == null ? 'max-w-lg' : 'max-w-screen-xl',
]"
>
<div
class="flex flex-row items-center justify-between gap-4 px-6 pt-6"
>
<div class="flex w-full flex-row">
<div class="flex grow flex-col gap-1.5">
<h3 class="leading-none font-semibold">Share</h3>
<p class="text-muted-foreground text-sm">
Created
{{
$dayjs(share.data.createdAt).format(
'MMM D, YYYY HH:mm'
)
}}
</p>
</div>
<div class="flex flex-row items-center justify-end gap-4">
<p>{{ share.file.name }}</p>
<DirectoryEntryIcon
:entry="{ ...share.file, name: '/' }"
/>
</div>
</div>
<div class="flex flex-row items-end">
<Button
:class="
share.file.fileType !== 'file' &&
entries == null &&
'hidden'
"
size="icon"
variant="outline"
@click="onDowloadClicked"
><Icon name="lucide:download"
/></Button>
</div>
</div>
<div class="flex w-full flex-col p-6">
<DirectoryList
v-if="entries != null"
:entries
:parent
:disable-entries="loading"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"
/>
<div
v-else-if="share.data.password"
class="flex h-full flex-col justify-between gap-2"
>
<div class="flex flex-col gap-1">
<Label for="password">Password</Label>
<Input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
<div class="flex flex-row-reverse items-end">
<Button
:disabled="loading || password.length < 1"
@click="submitPassword"
>Enter</Button
>
</div>
</div>
</div>
</div>
</div>
<div v-else class="bg-accent/20 rounded-md p-4">
<p class="text-destructive-foreground">Failed to get share</p>
</div>
</template>

View File

@@ -3,7 +3,8 @@ import { useDropZone } from '@vueuse/core';
import { toast } from 'vue-sonner';
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
import { getWarrenDirectory } from '~/lib/api/warrens';
import { fetchFile, getWarrenDirectory } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types';
definePageMeta({
middleware: ['authenticated'],
@@ -31,7 +32,10 @@ const dirData = useAsyncData(
'current-directory',
async () => {
if (warrenStore.current == null) {
return [];
return {
files: [],
parent: null,
};
}
loadingIndicator.start();
@@ -74,6 +78,57 @@ function onDrop(files: File[] | null, e: DragEvent) {
uploadStore.dialogOpen = true;
}
}
async function onEntryClicked(entry: DirectoryEntry) {
if (warrenStore.loading || warrenStore.current == null) {
return;
}
if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
}
function onEntryDownload(entry: DirectoryEntry) {
if (warrenStore.current == null) {
return;
}
if (entry.fileType !== 'file') {
toast.warning('Download', {
description: 'Directory downloads are not supported yet',
});
return;
}
downloadFile(
entry.name,
getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
);
}
function onBack() {
warrenStore.backCurrentPath();
}
</script>
<template>
@@ -87,6 +142,9 @@ function onDrop(files: File[] | null, e: DragEvent) {
"
:entries="dirData.files"
:parent="dirData.parent"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"
/>
</DirectoryListContextMenu>
<RenameEntryDialog />

View File

@@ -0,0 +1,13 @@
export type Share = {
id: string;
creatorId: string;
warrenId: string;
path: string;
password: boolean;
expiresAt: number | null;
createdAt: number;
};

View File

@@ -10,8 +10,14 @@ export type AdminWarrenData = WarrenData & {
export type UserWarren = {
userId: string;
warrenId: string;
canListFiles: boolean;
canReadFiles: boolean;
canModifyFiles: boolean;
canDeleteFiles: boolean;
canListShares: boolean;
canCreateShares: boolean;
canModifyShares: boolean;
canDeleteShares: boolean;
};

View File

@@ -30,12 +30,14 @@ export const useWarrenStore = defineStore('warrens', {
this.current.path += path;
},
backCurrentPath() {
backCurrentPath(): boolean {
if (this.current == null || this.current.path === '/') {
return;
return false;
}
this.current.path = getParentPath(this.current.path);
return true;
},
setCurrentWarrenPath(path: string) {
if (this.current == null) {
@@ -89,3 +91,20 @@ export const useRenameDirectoryDialog = defineStore('rename_directory_dialog', {
},
},
});
export const useShareDialog = defineStore('share_dialog', {
state: () => ({
target: null as DirectoryEntry | null,
password: '',
}),
actions: {
openDialog(target: DirectoryEntry) {
this.target = target;
this.password = '';
},
reset() {
this.target = null;
this.password = '';
},
},
});

View File

@@ -1,3 +1,5 @@
import type { Share } from '~/shared/types/shares';
export function getApiUrl(path: string): string {
const API_BASE_URL = useRuntimeConfig().public.apiBase;
return `${API_BASE_URL}/${path}`;
@@ -19,3 +21,7 @@ export function routeWithWarrenName(warrenId: string, path: string): string {
return `${warrenName}${path}`;
}
export function getShareLink(share: Share): string {
return `${window.location.origin}/share?id=${share.id}`;
}

View File

@@ -75,3 +75,14 @@ export async function pasteFile(
return success;
}
export function downloadFile(fileName: string, href: string) {
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = fileName;
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.click();
}

View File

@@ -27,3 +27,13 @@ export function trim(str: string, char: string) {
return start > 0 || end < str.length ? str.substring(start, end) : str;
}
export function copyToClipboard(content: string): boolean {
navigator.clipboard.writeText(content);
return navigator.clipboard != null;
}
export function capitalize(s: string): string {
return s.slice(0, 1).toUpperCase() + s.slice(1);
}

View File

@@ -4,7 +4,12 @@ export type UserWarrenPermissionKey =
| 'canListFiles'
| 'canReadFiles'
| 'canModifyFiles'
| 'canDeleteFiles';
| 'canDeleteFiles'
| 'canListShares'
| 'canListShares'
| 'canCreateShares'
| 'canModifyShares'
| 'canDeleteShares';
export function getUserWarrenPermissions(
userWarren: UserWarren
@@ -14,6 +19,10 @@ export function getUserWarrenPermissions(
['canReadFiles', userWarren.canReadFiles],
['canModifyFiles', userWarren.canModifyFiles],
['canDeleteFiles', userWarren.canDeleteFiles],
['canListShares', userWarren.canListShares],
['canCreateShares', userWarren.canCreateShares],
['canModifyShares', userWarren.canModifyShares],
['canDeleteShares', userWarren.canDeleteShares],
];
}
@@ -22,6 +31,10 @@ const PERMISSION_NAMES: Record<UserWarrenPermissionKey, string> = {
canReadFiles: 'Read files',
canModifyFiles: 'Modify files',
canDeleteFiles: 'Delete files',
canListShares: 'List shares',
canCreateShares: 'Create shares',
canModifyShares: 'Modify shares',
canDeleteShares: 'Delete shares',
};
export function getUserWarrenPermissionName(