From fa7fbe26e7b0707fc21d71e04af76870f5248baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Fri, 30 Jun 2023 11:31:25 +0200 Subject: [PATCH] feat: shared vaults functionality in api-gateway,auth,files,common,security,domain-events. (#629) Co-authored-by: Mo --- packages/api-gateway/bin/server.ts | 47 ++-- packages/api-gateway/src/Controller/index.ts | 2 + .../v1/AsymmetricMessagesController.ts | 17 ++ .../Controller/v1/SharedVaultsController.ts | 17 ++ packages/auth/package.json | 3 +- .../auth/src/Controller/AuthController.ts | 5 + packages/auth/src/Domain/Auth/AuthResponse.ts | 8 +- .../Auth/AuthResponseFactory20200115.ts | 8 +- .../src/Domain/Setting/SettingService.spec.ts | 6 +- .../auth/src/Domain/Setting/SettingService.ts | 5 +- .../SettingsAssociationService.spec.ts | 2 +- .../Setting/SettingsAssociationService.ts | 18 +- .../SettingsAssociationServiceInterface.ts | 2 +- .../CreateValetToken/CreateValetToken.ts | 3 +- .../InviteToSharedSubscription.ts | 4 +- .../auth/src/Domain/UseCase/RegisterDTO.ts | 2 + .../src/Domain/UseCase/UpdateUser.spec.ts | 8 - .../auth/src/Domain/UseCase/UpdateUser.ts | 18 +- .../auth/src/Domain/UseCase/UpdateUserDTO.ts | 13 +- packages/auth/src/Domain/User/User.spec.ts | 4 +- packages/auth/src/Domain/User/User.ts | 2 +- .../ValetToken/CreateValetTokenPayload.ts | 7 + .../HomeServer/HomeServerUsersController.ts | 9 - .../HomeServerValetTokenController.ts | 5 +- .../InversifyExpressUsersController.spec.ts | 4 - .../src/Projection/SimpleUserProjection.ts | 7 + packages/auth/src/Projection/UserProjector.ts | 3 +- .../src/Domain/Protocol/ProtocolVersion.ts | 1 - .../src/Domain/Event/DomainEventInterface.ts | 2 +- .../Event/SharedVaultFileRemovedEvent.ts | 7 + .../SharedVaultFileRemovedEventPayload.ts | 6 + .../Event/SharedVaultFileUploadedEvent.ts | 7 + .../SharedVaultFileUploadedEventPayload.ts | 6 + packages/domain-events/src/Domain/index.ts | 4 + .../event-store/src/Bootstrap/Container.ts | 1 + packages/files/bin/server.ts | 1 + packages/files/src/Bootstrap/Container.ts | 11 + packages/files/src/Bootstrap/Types.ts | 3 + .../Domain/Event/DomainEventFactory.spec.ts | 54 +++++ .../src/Domain/Event/DomainEventFactory.ts | 48 +++- .../Event/DomainEventFactoryInterface.ts | 19 +- ...countDeletionRequestedEventHandler.spec.ts | 4 +- .../AccountDeletionRequestedEventHandler.ts | 2 +- ...tionInvitationCanceledEventHandler.spec.ts | 4 +- ...scriptionInvitationCanceledEventHandler.ts | 2 +- .../src/Domain/Services/FileMoverInterface.ts | 3 + .../CreateUploadSession.spec.ts | 4 +- .../CreateUploadSession.ts | 2 +- .../CreateUploadSessionDTO.ts | 2 +- .../FinishUploadSession.spec.ts | 36 ++- .../FinishUploadSession.ts | 29 ++- .../FinishUploadSessionDTO.ts | 3 +- .../GetFileMetadata/GetFileMetadata.spec.ts | 4 +- .../GetFileMetadata/GetFileMetadata.ts | 4 +- .../GetFileMetadata/GetFileMetadataDTO.ts | 2 +- .../MarkFilesToBeRemoved.spec.ts | 4 +- .../MarkFilesToBeRemoved.ts | 6 +- .../MarkFilesToBeRemovedDTO.ts | 2 +- .../Domain/UseCase/MoveFile/MoveFile.spec.ts | 50 ++++ .../src/Domain/UseCase/MoveFile/MoveFile.ts | 32 +++ .../Domain/UseCase/MoveFile/MoveFileDTO.ts | 8 + .../UseCase/RemoveFile/RemoveFile.spec.ts | 48 +++- .../Domain/UseCase/RemoveFile/RemoveFile.ts | 59 +++-- .../UseCase/RemoveFile/RemoveFileDTO.ts | 14 +- .../UseCase/RemoveFile/RemoveFileResponse.ts | 8 - .../StreamDownloadFile.spec.ts | 4 +- .../StreamDownloadFile/StreamDownloadFile.ts | 4 +- .../StreamDownloadFileDTO.ts | 2 +- .../UploadFileChunk/UploadFileChunk.spec.ts | 8 +- .../UploadFileChunk/UploadFileChunk.ts | 2 +- .../UploadFileChunk/UploadFileChunkDTO.ts | 2 +- packages/files/src/Infra/FS/FSFileMover.ts | 22 ++ .../InversifyExpressFilesController.spec.ts | 18 +- .../InversifyExpressFilesController.ts | 23 +- ...ersifyExpressSharedVaultFilesController.ts | 215 ++++++++++++++++++ .../SharedVaultValetTokenAuthMiddleware.ts | 81 +++++++ packages/files/src/Infra/S3/S3FileMover.ts | 30 +++ .../src/Domain/Token/CrossServiceTokenData.ts | 1 + .../src/Domain/Token/SharedVaultMoveType.ts | 1 + .../Domain/Token/SharedVaultValetTokenData.ts | 16 ++ .../src/Domain/Token/ValetTokenOperation.ts | 1 + packages/security/src/Domain/index.ts | 2 + 82 files changed, 943 insertions(+), 220 deletions(-) create mode 100644 packages/api-gateway/src/Controller/v1/AsymmetricMessagesController.ts create mode 100644 packages/api-gateway/src/Controller/v1/SharedVaultsController.ts create mode 100644 packages/auth/src/Domain/ValetToken/CreateValetTokenPayload.ts create mode 100644 packages/auth/src/Projection/SimpleUserProjection.ts create mode 100644 packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEvent.ts create mode 100644 packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts create mode 100644 packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEvent.ts create mode 100644 packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts create mode 100644 packages/files/src/Domain/Services/FileMoverInterface.ts create mode 100644 packages/files/src/Domain/UseCase/MoveFile/MoveFile.spec.ts create mode 100644 packages/files/src/Domain/UseCase/MoveFile/MoveFile.ts create mode 100644 packages/files/src/Domain/UseCase/MoveFile/MoveFileDTO.ts delete mode 100644 packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts create mode 100644 packages/files/src/Infra/FS/FSFileMover.ts create mode 100644 packages/files/src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController.ts create mode 100644 packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts create mode 100644 packages/files/src/Infra/S3/S3FileMover.ts create mode 100644 packages/security/src/Domain/Token/SharedVaultMoveType.ts create mode 100644 packages/security/src/Domain/Token/SharedVaultValetTokenData.ts diff --git a/packages/api-gateway/bin/server.ts b/packages/api-gateway/bin/server.ts index 2a293c40c..831cf6d99 100644 --- a/packages/api-gateway/bin/server.ts +++ b/packages/api-gateway/bin/server.ts @@ -16,6 +16,8 @@ import '../src/Controller/v1/OfflineController' import '../src/Controller/v1/FilesController' import '../src/Controller/v1/SubscriptionInvitesController' import '../src/Controller/v1/AuthenticatorsController' +import '../src/Controller/v1/AsymmetricMessagesController' +import '../src/Controller/v1/SharedVaultsController' import '../src/Controller/v2/PaymentsControllerV2' import '../src/Controller/v2/ActionsControllerV2' @@ -45,28 +47,29 @@ void container.load().then((container) => { response.setHeader('X-API-Gateway-Version', container.get(TYPES.VERSION)) next() }) - /* eslint-disable */ - app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["https: 'self'"], - baseUri: ["'self'"], - childSrc: ["*", "blob:"], - connectSrc: ["*"], - fontSrc: ["*", "'self'"], - formAction: ["'self'"], - frameAncestors: ["*", "*.standardnotes.org", "*.standardnotes.com"], - frameSrc: ["*", "blob:"], - imgSrc: ["'self'", "*", "data:"], - manifestSrc: ["'self'"], - mediaSrc: ["'self'"], - objectSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'"] - } - } - })) - /* eslint-enable */ + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["https: 'self'"], + baseUri: ["'self'"], + childSrc: ['*', 'blob:'], + connectSrc: ['*'], + fontSrc: ['*', "'self'"], + formAction: ["'self'"], + frameAncestors: ['*', '*.standardnotes.org', '*.standardnotes.com'], + frameSrc: ['*', 'blob:'], + imgSrc: ["'self'", '*', 'data:'], + manifestSrc: ["'self'"], + mediaSrc: ["'self'"], + objectSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'"], + }, + }, + }), + ) + app.use(json({ limit: '50mb' })) app.use( text({ diff --git a/packages/api-gateway/src/Controller/index.ts b/packages/api-gateway/src/Controller/index.ts index f93454850..052de10df 100644 --- a/packages/api-gateway/src/Controller/index.ts +++ b/packages/api-gateway/src/Controller/index.ts @@ -4,6 +4,7 @@ export * from './SubscriptionTokenAuthMiddleware' export * from './TokenAuthenticationMethod' export * from './WebSocketAuthMiddleware' export * from './v1/ActionsController' +export * from './v1/AsymmetricMessagesController' export * from './v1/AuthenticatorsController' export * from './v1/FilesController' export * from './v1/InvoicesController' @@ -12,6 +13,7 @@ export * from './v1/OfflineController' export * from './v1/PaymentsController' export * from './v1/RevisionsController' export * from './v1/SessionsController' +export * from './v1/SharedVaultsController' export * from './v1/SubscriptionInvitesController' export * from './v1/TokensController' export * from './v1/UsersController' diff --git a/packages/api-gateway/src/Controller/v1/AsymmetricMessagesController.ts b/packages/api-gateway/src/Controller/v1/AsymmetricMessagesController.ts new file mode 100644 index 000000000..27747355d --- /dev/null +++ b/packages/api-gateway/src/Controller/v1/AsymmetricMessagesController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { BaseHttpController, controller, all } from 'inversify-express-utils' +import { TYPES } from '../../Bootstrap/Types' +import { ServiceProxyInterface } from '../../Service/Http/ServiceProxyInterface' + +@controller('/v1/asymmetric-messages') +export class AsymmetricMessagesController extends BaseHttpController { + constructor(@inject(TYPES.ServiceProxy) private serviceProxy: ServiceProxyInterface) { + super() + } + + @all('*', TYPES.RequiredCrossServiceTokenMiddleware) + async subscriptions(request: Request, response: Response): Promise { + await this.serviceProxy.callSyncingServer(request, response, request.path.replace('/v1/', ''), request.body) + } +} diff --git a/packages/api-gateway/src/Controller/v1/SharedVaultsController.ts b/packages/api-gateway/src/Controller/v1/SharedVaultsController.ts new file mode 100644 index 000000000..ed414a0e2 --- /dev/null +++ b/packages/api-gateway/src/Controller/v1/SharedVaultsController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { BaseHttpController, controller, all } from 'inversify-express-utils' +import { TYPES } from '../../Bootstrap/Types' +import { ServiceProxyInterface } from '../../Service/Http/ServiceProxyInterface' + +@controller('/v1/shared-vaults') +export class SharedVaultsController extends BaseHttpController { + constructor(@inject(TYPES.ServiceProxy) private serviceProxy: ServiceProxyInterface) { + super() + } + + @all('*', TYPES.RequiredCrossServiceTokenMiddleware) + async subscriptions(request: Request, response: Response): Promise { + await this.serviceProxy.callSyncingServer(request, response, request.path.replace('/v1/', ''), request.body) + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json index a50382400..257b4ebae 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -32,7 +32,8 @@ "weekly-backup:email": "yarn node dist/bin/backup.js email weekly", "content-recalculation": "yarn node dist/bin/content.js", "typeorm": "typeorm-ts-node-commonjs", - "upgrade:snjs": "yarn ncu -u '@standardnotes/*'" + "upgrade:snjs": "yarn ncu -u '@standardnotes/*'", + "migrate": "yarn build && yarn typeorm migration:run -d dist/src/Bootstrap/DataSource.js" }, "dependencies": { "@aws-sdk/client-sns": "^3.332.0", diff --git a/packages/auth/src/Controller/AuthController.ts b/packages/auth/src/Controller/AuthController.ts index 17ecb6c8b..bd77580a1 100644 --- a/packages/auth/src/Controller/AuthController.ts +++ b/packages/auth/src/Controller/AuthController.ts @@ -63,6 +63,11 @@ export class AuthController implements UserServerInterface { kpOrigination: params.origination, kpCreated: params.created, version: params.version, + // @TODO: awaiting publishing of new standardnotes/api package + // eslint-disable-next-line @typescript-eslint/no-explicit-any + publicKey: (params as any).public_key, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signingPublicKey: (params as any).signing_key_public, }) if (!registerResult.success) { diff --git a/packages/auth/src/Domain/Auth/AuthResponse.ts b/packages/auth/src/Domain/Auth/AuthResponse.ts index cb61d0dd1..f6210d414 100644 --- a/packages/auth/src/Domain/Auth/AuthResponse.ts +++ b/packages/auth/src/Domain/Auth/AuthResponse.ts @@ -1,9 +1,5 @@ -import { ProtocolVersion } from '@standardnotes/common' +import { SimpleUserProjection } from '../../Projection/SimpleUserProjection' export interface AuthResponse { - user: { - uuid: string - email: string - protocolVersion: ProtocolVersion - } + user: SimpleUserProjection } diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts index 368385d65..a8f8722d0 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts @@ -4,13 +4,13 @@ import { TokenEncoderInterface, } from '@standardnotes/security' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' -import { ProtocolVersion } from '@standardnotes/common' import { SessionBody } from '@standardnotes/responses' import { inject, injectable } from 'inversify' import { Logger } from 'winston' import TYPES from '../../Bootstrap/Types' import { ProjectorInterface } from '../../Projection/ProjectorInterface' +import { SimpleUserProjection } from '../../Projection/SimpleUserProjection' import { SessionServiceInterface } from '../Session/SessionServiceInterface' import { KeyParamsFactoryInterface } from '../User/KeyParamsFactoryInterface' import { User } from '../User/User' @@ -54,11 +54,7 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { return { session: sessionPayload, key_params: this.keyParamsFactory.create(dto.user, true), - user: this.userProjector.projectSimple(dto.user) as { - uuid: string - email: string - protocolVersion: ProtocolVersion - }, + user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection, } } diff --git a/packages/auth/src/Domain/Setting/SettingService.spec.ts b/packages/auth/src/Domain/Setting/SettingService.spec.ts index adb07dbfd..71d4caaca 100644 --- a/packages/auth/src/Domain/Setting/SettingService.spec.ts +++ b/packages/auth/src/Domain/Setting/SettingService.spec.ts @@ -37,7 +37,7 @@ describe('SettingService', () => { user = { uuid: '4-5-6', } as jest.Mocked - user.isPotentiallyAVaultAccount = jest.fn().mockReturnValue(false) + user.isPotentiallyAPrivateUsernameAccount = jest.fn().mockReturnValue(false) setting = { name: SettingName.NAMES.DropboxBackupToken, @@ -66,7 +66,7 @@ describe('SettingService', () => { ]), ) - settingsAssociationService.getDefaultSettingsAndValuesForNewVaultAccount = jest.fn().mockReturnValue( + settingsAssociationService.getDefaultSettingsAndValuesForNewPrivateUsernameAccount = jest.fn().mockReturnValue( new Map([ [ SettingName.NAMES.LogSessionUserAgent, @@ -98,7 +98,7 @@ describe('SettingService', () => { }) it('should create default settings for a newly registered vault account', async () => { - user.isPotentiallyAVaultAccount = jest.fn().mockReturnValue(true) + user.isPotentiallyAPrivateUsernameAccount = jest.fn().mockReturnValue(true) await createService().applyDefaultSettingsUponRegistration(user) diff --git a/packages/auth/src/Domain/Setting/SettingService.ts b/packages/auth/src/Domain/Setting/SettingService.ts index 5d3fa868a..264086936 100644 --- a/packages/auth/src/Domain/Setting/SettingService.ts +++ b/packages/auth/src/Domain/Setting/SettingService.ts @@ -28,8 +28,9 @@ export class SettingService implements SettingServiceInterface { async applyDefaultSettingsUponRegistration(user: User): Promise { let defaultSettingsWithValues = this.settingsAssociationService.getDefaultSettingsAndValuesForNewUser() - if (user.isPotentiallyAVaultAccount()) { - defaultSettingsWithValues = this.settingsAssociationService.getDefaultSettingsAndValuesForNewVaultAccount() + if (user.isPotentiallyAPrivateUsernameAccount()) { + defaultSettingsWithValues = + this.settingsAssociationService.getDefaultSettingsAndValuesForNewPrivateUsernameAccount() } for (const settingName of defaultSettingsWithValues.keys()) { diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts b/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts index 9f02b8645..3fa815673 100644 --- a/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts +++ b/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts @@ -55,7 +55,7 @@ describe('SettingsAssociationService', () => { }) it('should return the default set of settings for a newly registered vault account', () => { - const settings = createService().getDefaultSettingsAndValuesForNewVaultAccount() + const settings = createService().getDefaultSettingsAndValuesForNewPrivateUsernameAccount() const flatSettings = [...(settings as Map).keys()] expect(flatSettings).toEqual(['MUTE_MARKETING_EMAILS', 'LOG_SESSION_USER_AGENT']) diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationService.ts b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts index 8ecdf651a..6ce6ede33 100644 --- a/packages/auth/src/Domain/Setting/SettingsAssociationService.ts +++ b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts @@ -66,7 +66,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt ], ]) - private readonly vaultAccountDefaultSettingsOverwrites = new Map([ + private readonly privateUsernameAccountDefaultSettingsOverwrites = new Map([ [ SettingName.NAMES.LogSessionUserAgent, { @@ -114,16 +114,18 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt return this.defaultSettings } - getDefaultSettingsAndValuesForNewVaultAccount(): Map { - const defaultVaultSettings = new Map(this.defaultSettings) + getDefaultSettingsAndValuesForNewPrivateUsernameAccount(): Map { + const defaultPrivateUsernameSettings = new Map(this.defaultSettings) - for (const vaultAccountDefaultSettingOverwriteKey of this.vaultAccountDefaultSettingsOverwrites.keys()) { - defaultVaultSettings.set( - vaultAccountDefaultSettingOverwriteKey, - this.vaultAccountDefaultSettingsOverwrites.get(vaultAccountDefaultSettingOverwriteKey) as SettingDescription, + for (const privateUsernameAccountDefaultSettingOverwriteKey of this.privateUsernameAccountDefaultSettingsOverwrites.keys()) { + defaultPrivateUsernameSettings.set( + privateUsernameAccountDefaultSettingOverwriteKey, + this.privateUsernameAccountDefaultSettingsOverwrites.get( + privateUsernameAccountDefaultSettingOverwriteKey, + ) as SettingDescription, ) } - return defaultVaultSettings + return defaultPrivateUsernameSettings } } diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts b/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts index 80a32b896..73b5dbcb2 100644 --- a/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts +++ b/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts @@ -6,7 +6,7 @@ import { SettingDescription } from './SettingDescription' export interface SettingsAssociationServiceInterface { getDefaultSettingsAndValuesForNewUser(): Map - getDefaultSettingsAndValuesForNewVaultAccount(): Map + getDefaultSettingsAndValuesForNewPrivateUsernameAccount(): Map getPermissionAssociatedWithSetting(settingName: SettingName): PermissionName | undefined getEncryptionVersionForSetting(settingName: SettingName): EncryptionVersion getSensitivityForSetting(settingName: SettingName): boolean diff --git a/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts index 38210ea35..cc1901315 100644 --- a/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts +++ b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts @@ -2,7 +2,7 @@ import { inject, injectable } from 'inversify' import { SubscriptionName } from '@standardnotes/common' import { TimerInterface } from '@standardnotes/time' import { TokenEncoderInterface, ValetTokenData } from '@standardnotes/security' -import { CreateValetTokenPayload, CreateValetTokenResponseData } from '@standardnotes/responses' +import { CreateValetTokenResponseData } from '@standardnotes/responses' import { SettingName } from '@standardnotes/settings' import TYPES from '../../../Bootstrap/Types' @@ -12,6 +12,7 @@ import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionS import { CreateValetTokenDTO } from './CreateValetTokenDTO' import { SubscriptionSettingsAssociationServiceInterface } from '../../Setting/SubscriptionSettingsAssociationServiceInterface' import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' +import { CreateValetTokenPayload } from '../../ValetToken/CreateValetTokenPayload' @injectable() export class CreateValetToken implements UseCaseInterface { diff --git a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts index 4c7d2e7f8..77c8edbc6 100644 --- a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts +++ b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts @@ -69,7 +69,7 @@ export class InviteToSharedSubscription implements UseCaseInterface { sharedSubscriptionInvition.inviterIdentifier = dto.inviterEmail sharedSubscriptionInvition.inviterIdentifierType = InviterIdentifierType.Email sharedSubscriptionInvition.inviteeIdentifier = dto.inviteeIdentifier - sharedSubscriptionInvition.inviteeIdentifierType = this.isInviteeIdentifierPotentiallyAVaultAccount( + sharedSubscriptionInvition.inviteeIdentifierType = this.isInviteeIdentifierPotentiallyAPrivateUsernameAccount( dto.inviteeIdentifier, ) ? InviteeIdentifierType.Hash @@ -107,7 +107,7 @@ export class InviteToSharedSubscription implements UseCaseInterface { } } - private isInviteeIdentifierPotentiallyAVaultAccount(identifier: string): boolean { + private isInviteeIdentifierPotentiallyAPrivateUsernameAccount(identifier: string): boolean { return identifier.length === 64 && !identifier.includes('@') } } diff --git a/packages/auth/src/Domain/UseCase/RegisterDTO.ts b/packages/auth/src/Domain/UseCase/RegisterDTO.ts index 85bc1276e..8769b65a8 100644 --- a/packages/auth/src/Domain/UseCase/RegisterDTO.ts +++ b/packages/auth/src/Domain/UseCase/RegisterDTO.ts @@ -10,4 +10,6 @@ export type RegisterDTO = { kpOrigination?: string kpCreated?: string version?: string + publicKey?: string + signingPublicKey?: string } diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts b/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts index 95813822e..11bbfa20e 100644 --- a/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts +++ b/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts @@ -44,21 +44,13 @@ describe('UpdateUser', () => { user, updatedWithUserAgent: 'Mozilla', apiVersion: '20190520', - version: '004', - pwCost: 11, - pwSalt: 'qweqwe', - pwNonce: undefined, }), ).toEqual({ success: true, authResponse: { foo: 'bar' } }) expect(userRepository.save).toHaveBeenCalledWith({ createdAt: new Date(1), - pwCost: 11, email: 'test@test.te', - pwSalt: 'qweqwe', - updatedWithUserAgent: 'Mozilla', uuid: '123', - version: '004', updatedAt: new Date(1), }) }) diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.ts b/packages/auth/src/Domain/UseCase/UpdateUser.ts index 863081924..087438e70 100644 --- a/packages/auth/src/Domain/UseCase/UpdateUser.ts +++ b/packages/auth/src/Domain/UseCase/UpdateUser.ts @@ -17,25 +17,17 @@ export class UpdateUser implements UseCaseInterface { ) {} async execute(dto: UpdateUserDTO): Promise { - const { user, apiVersion, ...updateFields } = dto + dto.user.updatedAt = this.timer.getUTCDate() - Object.keys(updateFields).forEach( - (key) => (updateFields[key] === undefined || updateFields[key] === null) && delete updateFields[key], - ) + const updatedUser = await this.userRepository.save(dto.user) - Object.assign(user, updateFields) - - user.updatedAt = this.timer.getUTCDate() - - await this.userRepository.save(user) - - const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion) + const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion) return { success: true, authResponse: await authResponseFactory.createResponse({ - user, - apiVersion, + user: updatedUser, + apiVersion: dto.apiVersion, userAgent: dto.updatedWithUserAgent, ephemeralSession: false, readonlyAccess: false, diff --git a/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts b/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts index 90b3bf468..efc2dd13d 100644 --- a/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts +++ b/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts @@ -1,18 +1,7 @@ import { User } from '../User/User' export type UpdateUserDTO = { - [key: string]: string | User | Date | undefined | number user: User - updatedWithUserAgent: string apiVersion: string - email?: string - pwFunc?: string - pwAlg?: string - pwCost?: number - pwKeySize?: number - pwNonce?: string - pwSalt?: string - kpOrigination?: string - kpCreated?: Date - version?: string + updatedWithUserAgent: string } diff --git a/packages/auth/src/Domain/User/User.spec.ts b/packages/auth/src/Domain/User/User.spec.ts index f1d02f8c2..e19458bce 100644 --- a/packages/auth/src/Domain/User/User.spec.ts +++ b/packages/auth/src/Domain/User/User.spec.ts @@ -21,13 +21,13 @@ describe('User', () => { const user = createUser() user.email = 'a75a31ce95365904ef0e0a8e6cefc1f5e99adfef81bbdb6d4499eeb10ae0ff67' - expect(user.isPotentiallyAVaultAccount()).toBeTruthy() + expect(user.isPotentiallyAPrivateUsernameAccount()).toBeTruthy() }) it('should indicate if the user is not a vault account', () => { const user = createUser() user.email = 'test@test.te' - expect(user.isPotentiallyAVaultAccount()).toBeFalsy() + expect(user.isPotentiallyAPrivateUsernameAccount()).toBeFalsy() }) }) diff --git a/packages/auth/src/Domain/User/User.ts b/packages/auth/src/Domain/User/User.ts index 81b119c82..ceaac70c5 100644 --- a/packages/auth/src/Domain/User/User.ts +++ b/packages/auth/src/Domain/User/User.ts @@ -202,7 +202,7 @@ export class User { return parseInt(this.version) >= parseInt(ProtocolVersion.V004) } - isPotentiallyAVaultAccount(): boolean { + isPotentiallyAPrivateUsernameAccount(): boolean { return this.email.length === 64 && !this.email.includes('@') } } diff --git a/packages/auth/src/Domain/ValetToken/CreateValetTokenPayload.ts b/packages/auth/src/Domain/ValetToken/CreateValetTokenPayload.ts new file mode 100644 index 000000000..1b10ae343 --- /dev/null +++ b/packages/auth/src/Domain/ValetToken/CreateValetTokenPayload.ts @@ -0,0 +1,7 @@ +export type CreateValetTokenPayload = { + operation: 'read' | 'write' | 'delete' | 'move' + resources: Array<{ + remoteIdentifier: string + unencryptedFileSize?: number + }> +} diff --git a/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts index 67d499eb4..7de85df59 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts @@ -60,15 +60,6 @@ export class HomeServerUsersController extends BaseHttpController { user: response.locals.user, updatedWithUserAgent: request.headers['user-agent'], apiVersion: request.body.api, - pwFunc: request.body.pw_func, - pwAlg: request.body.pw_alg, - pwCost: request.body.pw_cost, - pwKeySize: request.body.pw_key_size, - pwNonce: request.body.pw_nonce, - pwSalt: request.body.pw_salt, - kpOrigination: request.body.origination, - kpCreated: request.body.created, - version: request.body.version, }) if (updateResult.success) { diff --git a/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerValetTokenController.ts b/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerValetTokenController.ts index e87a49117..0e98c1585 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerValetTokenController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerValetTokenController.ts @@ -1,10 +1,11 @@ import { ControllerContainerInterface, Uuid } from '@standardnotes/domain-core' import { Request, Response } from 'express' import { BaseHttpController, results } from 'inversify-express-utils' +import { ErrorTag } from '@standardnotes/responses' +import { ValetTokenOperation } from '@standardnotes/security' import { CreateValetToken } from '../../../Domain/UseCase/CreateValetToken/CreateValetToken' -import { CreateValetTokenPayload, ErrorTag } from '@standardnotes/responses' -import { ValetTokenOperation } from '@standardnotes/security' +import { CreateValetTokenPayload } from '../../../Domain/ValetToken/CreateValetTokenPayload' export class HomeServerValetTokenController extends BaseHttpController { constructor(protected createValetKey: CreateValetToken, private controllerContainer?: ControllerContainerInterface) { diff --git a/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressUsersController.spec.ts b/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressUsersController.spec.ts index 378879cd4..13d273ad0 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressUsersController.spec.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressUsersController.spec.ts @@ -99,9 +99,7 @@ describe('InversifyExpressUsersController', () => { expect(updateUser.execute).toHaveBeenCalledWith({ apiVersion: '20190520', - kpOrigination: 'test', updatedWithUserAgent: 'Google Chrome', - version: '002', user: { uuid: '123', email: 'test@test.te', @@ -143,9 +141,7 @@ describe('InversifyExpressUsersController', () => { expect(updateUser.execute).toHaveBeenCalledWith({ apiVersion: '20190520', - kpOrigination: 'test', updatedWithUserAgent: 'Google Chrome', - version: '002', user: { uuid: '123', email: 'test@test.te', diff --git a/packages/auth/src/Projection/SimpleUserProjection.ts b/packages/auth/src/Projection/SimpleUserProjection.ts new file mode 100644 index 000000000..7c773a32c --- /dev/null +++ b/packages/auth/src/Projection/SimpleUserProjection.ts @@ -0,0 +1,7 @@ +export type SimpleUserProjection = { + uuid: string + email: string + protocolVersion: string + publicKey?: string + signingPublicKey?: string +} diff --git a/packages/auth/src/Projection/UserProjector.ts b/packages/auth/src/Projection/UserProjector.ts index 10abb40ae..3ff6e3471 100644 --- a/packages/auth/src/Projection/UserProjector.ts +++ b/packages/auth/src/Projection/UserProjector.ts @@ -2,10 +2,11 @@ import { injectable } from 'inversify' import { User } from '../Domain/User/User' import { ProjectorInterface } from './ProjectorInterface' +import { SimpleUserProjection } from './SimpleUserProjection' @injectable() export class UserProjector implements ProjectorInterface { - projectSimple(user: User): Record { + projectSimple(user: User): SimpleUserProjection { return { uuid: user.uuid, email: user.email, diff --git a/packages/common/src/Domain/Protocol/ProtocolVersion.ts b/packages/common/src/Domain/Protocol/ProtocolVersion.ts index 7f8efe007..9b3f7068c 100644 --- a/packages/common/src/Domain/Protocol/ProtocolVersion.ts +++ b/packages/common/src/Domain/Protocol/ProtocolVersion.ts @@ -3,7 +3,6 @@ export enum ProtocolVersion { V002 = '002', V003 = '003', V004 = '004', - V005 = '005', } export const ProtocolVersionLatest = ProtocolVersion.V004 diff --git a/packages/domain-events/src/Domain/Event/DomainEventInterface.ts b/packages/domain-events/src/Domain/Event/DomainEventInterface.ts index a5fd42f1d..0732e253a 100644 --- a/packages/domain-events/src/Domain/Event/DomainEventInterface.ts +++ b/packages/domain-events/src/Domain/Event/DomainEventInterface.ts @@ -7,7 +7,7 @@ export interface DomainEventInterface { meta: { correlation: { userIdentifier: string - userIdentifierType: 'uuid' | 'email' + userIdentifierType: 'uuid' | 'email' | 'shared-vault-uuid' } origin: DomainEventService target?: DomainEventService diff --git a/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEvent.ts b/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEvent.ts new file mode 100644 index 000000000..b167d456d --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { SharedVaultFileRemovedEventPayload } from './SharedVaultFileRemovedEventPayload' + +export interface SharedVaultFileRemovedEvent extends DomainEventInterface { + type: 'SHARED_VAULT_FILE_REMOVED' + payload: SharedVaultFileRemovedEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts b/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts new file mode 100644 index 000000000..06cce157c --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts @@ -0,0 +1,6 @@ +export interface SharedVaultFileRemovedEventPayload { + sharedVaultUuid: string + fileByteSize: number + filePath: string + fileName: string +} diff --git a/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEvent.ts b/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEvent.ts new file mode 100644 index 000000000..acff7213e --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { SharedVaultFileUploadedEventPayload } from './SharedVaultFileUploadedEventPayload' + +export interface SharedVaultFileUploadedEvent extends DomainEventInterface { + type: 'SHARED_VAULT_FILE_UPLOADED' + payload: SharedVaultFileUploadedEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts b/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts new file mode 100644 index 000000000..7334fb9f6 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts @@ -0,0 +1,6 @@ +export interface SharedVaultFileUploadedEventPayload { + sharedVaultUuid: string + fileByteSize: number + filePath: string + fileName: string +} diff --git a/packages/domain-events/src/Domain/index.ts b/packages/domain-events/src/Domain/index.ts index 6dbe1734b..8c42d4a40 100644 --- a/packages/domain-events/src/Domain/index.ts +++ b/packages/domain-events/src/Domain/index.ts @@ -62,6 +62,10 @@ export * from './Event/SharedSubscriptionInvitationCanceledEvent' export * from './Event/SharedSubscriptionInvitationCanceledEventPayload' export * from './Event/SharedSubscriptionInvitationCreatedEvent' export * from './Event/SharedSubscriptionInvitationCreatedEventPayload' +export * from './Event/SharedVaultFileRemovedEvent' +export * from './Event/SharedVaultFileRemovedEventPayload' +export * from './Event/SharedVaultFileUploadedEvent' +export * from './Event/SharedVaultFileUploadedEventPayload' export * from './Event/StatisticPersistenceRequestedEvent' export * from './Event/StatisticPersistenceRequestedEventPayload' export * from './Event/SubscriptionCancelledEvent' diff --git a/packages/event-store/src/Bootstrap/Container.ts b/packages/event-store/src/Bootstrap/Container.ts index 79a4cda85..0d5fd0467 100644 --- a/packages/event-store/src/Bootstrap/Container.ts +++ b/packages/event-store/src/Bootstrap/Container.ts @@ -78,6 +78,7 @@ export class ContainerConfigLoader { ['SUBSCRIPTION_EXPIRED', container.get(TYPES.EventHandler)], ['EXTENSION_KEY_GRANTED', container.get(TYPES.EventHandler)], ['SUBSCRIPTION_REASSIGNED', container.get(TYPES.EventHandler)], + ['USER_CREDENTIALS_CHANGED', container.get(TYPES.EventHandler)], ['USER_EMAIL_CHANGED', container.get(TYPES.EventHandler)], ['FILE_UPLOADED', container.get(TYPES.EventHandler)], ['FILE_REMOVED', container.get(TYPES.EventHandler)], diff --git a/packages/files/bin/server.ts b/packages/files/bin/server.ts index 14bc31e7c..9743286c4 100644 --- a/packages/files/bin/server.ts +++ b/packages/files/bin/server.ts @@ -4,6 +4,7 @@ import * as busboy from 'connect-busboy' import '../src/Infra/InversifyExpress/InversifyExpressHealthCheckController' import '../src/Infra/InversifyExpress/InversifyExpressFilesController' +import '../src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController' import helmet from 'helmet' import * as cors from 'cors' diff --git a/packages/files/src/Bootstrap/Container.ts b/packages/files/src/Bootstrap/Container.ts index 27dcb0497..8ca9b7745 100644 --- a/packages/files/src/Bootstrap/Container.ts +++ b/packages/files/src/Bootstrap/Container.ts @@ -48,6 +48,11 @@ import { AccountDeletionRequestedEventHandler } from '../Domain/Handler/AccountD import { SharedSubscriptionInvitationCanceledEventHandler } from '../Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler' import { InMemoryUploadRepository } from '../Infra/InMemory/InMemoryUploadRepository' import { Transform } from 'stream' +import { FileMoverInterface } from '../Domain/Services/FileMoverInterface' +import { S3FileMover } from '../Infra/S3/S3FileMover' +import { FSFileMover } from '../Infra/FS/FSFileMover' +import { MoveFile } from '../Domain/UseCase/MoveFile/MoveFile' +import { SharedVaultValetTokenAuthMiddleware } from '../Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware' export class ContainerConfigLoader { async load(configuration?: { @@ -177,6 +182,7 @@ export class ContainerConfigLoader { container.bind(TYPES.Files_FileDownloader).to(S3FileDownloader) container.bind(TYPES.Files_FileUploader).to(S3FileUploader) container.bind(TYPES.Files_FileRemover).to(S3FileRemover) + container.bind(TYPES.Files_FileMover).to(S3FileMover) } else { container.bind(TYPES.Files_FileDownloader).to(FSFileDownloader) container @@ -185,6 +191,7 @@ export class ContainerConfigLoader { new FSFileUploader(container.get(TYPES.Files_FILE_UPLOAD_PATH), container.get(TYPES.Files_Logger)), ) container.bind(TYPES.Files_FileRemover).to(FSFileRemover) + container.bind(TYPES.Files_FileMover).to(FSFileMover) } // use cases @@ -194,10 +201,14 @@ export class ContainerConfigLoader { container.bind(TYPES.Files_FinishUploadSession).to(FinishUploadSession) container.bind(TYPES.Files_GetFileMetadata).to(GetFileMetadata) container.bind(TYPES.Files_RemoveFile).to(RemoveFile) + container.bind(TYPES.Files_MoveFile).to(MoveFile) container.bind(TYPES.Files_MarkFilesToBeRemoved).to(MarkFilesToBeRemoved) // middleware container.bind(TYPES.Files_ValetTokenAuthMiddleware).to(ValetTokenAuthMiddleware) + container + .bind(TYPES.Files_SharedVaultValetTokenAuthMiddleware) + .to(SharedVaultValetTokenAuthMiddleware) // services container diff --git a/packages/files/src/Bootstrap/Types.ts b/packages/files/src/Bootstrap/Types.ts index 8ae24fe10..3fd8b27d0 100644 --- a/packages/files/src/Bootstrap/Types.ts +++ b/packages/files/src/Bootstrap/Types.ts @@ -13,6 +13,7 @@ const TYPES = { Files_FinishUploadSession: Symbol.for('Files_FinishUploadSession'), Files_GetFileMetadata: Symbol.for('Files_GetFileMetadata'), Files_RemoveFile: Symbol.for('Files_RemoveFile'), + Files_MoveFile: Symbol.for('Files_MoveFile'), Files_MarkFilesToBeRemoved: Symbol.for('Files_MarkFilesToBeRemoved'), // services @@ -23,12 +24,14 @@ const TYPES = { Files_FileUploader: Symbol.for('Files_FileUploader'), Files_FileDownloader: Symbol.for('Files_FileDownloader'), Files_FileRemover: Symbol.for('Files_FileRemover'), + Files_FileMover: Symbol.for('Files_FileMover'), // repositories Files_UploadRepository: Symbol.for('Files_UploadRepository'), // middleware Files_ValetTokenAuthMiddleware: Symbol.for('Files_ValetTokenAuthMiddleware'), + Files_SharedVaultValetTokenAuthMiddleware: Symbol.for('Files_SharedVaultValetTokenAuthMiddleware'), // env vars Files_S3_ENDPOINT: Symbol.for('Files_S3_ENDPOINT'), diff --git a/packages/files/src/Domain/Event/DomainEventFactory.spec.ts b/packages/files/src/Domain/Event/DomainEventFactory.spec.ts index 260778b4d..18a0c5d51 100644 --- a/packages/files/src/Domain/Event/DomainEventFactory.spec.ts +++ b/packages/files/src/Domain/Event/DomainEventFactory.spec.ts @@ -14,6 +14,60 @@ describe('DomainEventFactory', () => { timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) }) + it('should create a SHARED_VAULT_FILE_UPLOADED event', () => { + expect( + createFactory().createSharedVaultFileUploadedEvent({ + sharedVaultUuid: '1-2-3', + filePath: 'foo/bar', + fileName: 'baz', + fileByteSize: 123, + }), + ).toEqual({ + createdAt: new Date(1), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'shared-vault-uuid', + }, + origin: 'files', + }, + payload: { + sharedVaultUuid: '1-2-3', + filePath: 'foo/bar', + fileName: 'baz', + fileByteSize: 123, + }, + type: 'SHARED_VAULT_FILE_UPLOADED', + }) + }) + + it('should create a SHARED_VAULT_FILE_REMOVED event', () => { + expect( + createFactory().createSharedVaultFileRemovedEvent({ + sharedVaultUuid: '1-2-3', + filePath: 'foo/bar', + fileName: 'baz', + fileByteSize: 123, + }), + ).toEqual({ + createdAt: new Date(1), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'shared-vault-uuid', + }, + origin: 'files', + }, + payload: { + sharedVaultUuid: '1-2-3', + filePath: 'foo/bar', + fileName: 'baz', + fileByteSize: 123, + }, + type: 'SHARED_VAULT_FILE_REMOVED', + }) + }) + it('should create a FILE_UPLOADED event', () => { expect( createFactory().createFileUploadedEvent({ diff --git a/packages/files/src/Domain/Event/DomainEventFactory.ts b/packages/files/src/Domain/Event/DomainEventFactory.ts index 6b426e173..99acbd5fb 100644 --- a/packages/files/src/Domain/Event/DomainEventFactory.ts +++ b/packages/files/src/Domain/Event/DomainEventFactory.ts @@ -1,4 +1,10 @@ -import { FileUploadedEvent, FileRemovedEvent, DomainEventService } from '@standardnotes/domain-events' +import { + FileUploadedEvent, + FileRemovedEvent, + DomainEventService, + SharedVaultFileUploadedEvent, + SharedVaultFileRemovedEvent, +} from '@standardnotes/domain-events' import { TimerInterface } from '@standardnotes/time' import { inject, injectable } from 'inversify' @@ -49,4 +55,44 @@ export class DomainEventFactory implements DomainEventFactoryInterface { payload, } } + + createSharedVaultFileUploadedEvent(payload: { + sharedVaultUuid: string + filePath: string + fileName: string + fileByteSize: number + }): SharedVaultFileUploadedEvent { + return { + type: 'SHARED_VAULT_FILE_UPLOADED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: payload.sharedVaultUuid, + userIdentifierType: 'shared-vault-uuid', + }, + origin: DomainEventService.Files, + }, + payload, + } + } + + createSharedVaultFileRemovedEvent(payload: { + sharedVaultUuid: string + filePath: string + fileName: string + fileByteSize: number + }): SharedVaultFileRemovedEvent { + return { + type: 'SHARED_VAULT_FILE_REMOVED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: payload.sharedVaultUuid, + userIdentifierType: 'shared-vault-uuid', + }, + origin: DomainEventService.Files, + }, + payload, + } + } } diff --git a/packages/files/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/files/src/Domain/Event/DomainEventFactoryInterface.ts index da7ac674c..6463ac8ff 100644 --- a/packages/files/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/files/src/Domain/Event/DomainEventFactoryInterface.ts @@ -1,4 +1,9 @@ -import { FileUploadedEvent, FileRemovedEvent } from '@standardnotes/domain-events' +import { + FileUploadedEvent, + FileRemovedEvent, + SharedVaultFileRemovedEvent, + SharedVaultFileUploadedEvent, +} from '@standardnotes/domain-events' export interface DomainEventFactoryInterface { createFileUploadedEvent(payload: { @@ -14,4 +19,16 @@ export interface DomainEventFactoryInterface { fileByteSize: number regularSubscriptionUuid: string }): FileRemovedEvent + createSharedVaultFileUploadedEvent(payload: { + sharedVaultUuid: string + filePath: string + fileName: string + fileByteSize: number + }): SharedVaultFileUploadedEvent + createSharedVaultFileRemovedEvent(payload: { + sharedVaultUuid: string + filePath: string + fileName: string + fileByteSize: number + }): SharedVaultFileRemovedEvent } diff --git a/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts b/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts index 4fb6380ed..1ca633278 100644 --- a/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts +++ b/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts @@ -44,7 +44,7 @@ describe('AccountDeletionRequestedEventHandler', () => { it('should mark files to be remove for user', async () => { await createHandler().handle(event) - expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ ownerUuid: '1-2-3' }) expect(domainEventPublisher.publish).toHaveBeenCalled() }) @@ -66,7 +66,7 @@ describe('AccountDeletionRequestedEventHandler', () => { await createHandler().handle(event) - expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ ownerUuid: '1-2-3' }) expect(domainEventPublisher.publish).not.toHaveBeenCalled() }) diff --git a/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts b/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts index 618c5a420..8790c7bba 100644 --- a/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts +++ b/packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts @@ -23,7 +23,7 @@ export class AccountDeletionRequestedEventHandler implements DomainEventHandlerI } const response = await this.markFilesToBeRemoved.execute({ - userUuid: event.payload.userUuid, + ownerUuid: event.payload.userUuid, }) if (!response.success) { diff --git a/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.spec.ts b/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.spec.ts index 5bbcc036f..be3ce18ef 100644 --- a/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.spec.ts +++ b/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.spec.ts @@ -44,7 +44,7 @@ describe('SharedSubscriptionInvitationCanceledEventHandler', () => { it('should mark files to be remove for user', async () => { await createHandler().handle(event) - expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ ownerUuid: '1-2-3' }) expect(domainEventPublisher.publish).toHaveBeenCalled() }) @@ -66,7 +66,7 @@ describe('SharedSubscriptionInvitationCanceledEventHandler', () => { await createHandler().handle(event) - expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + expect(markFilesToBeRemoved.execute).toHaveBeenCalledWith({ ownerUuid: '1-2-3' }) expect(domainEventPublisher.publish).not.toHaveBeenCalled() }) diff --git a/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.ts b/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.ts index b71587498..c122aa8b3 100644 --- a/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.ts +++ b/packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.ts @@ -23,7 +23,7 @@ export class SharedSubscriptionInvitationCanceledEventHandler implements DomainE } const response = await this.markFilesToBeRemoved.execute({ - userUuid: event.payload.inviteeIdentifier, + ownerUuid: event.payload.inviteeIdentifier, }) if (!response.success) { diff --git a/packages/files/src/Domain/Services/FileMoverInterface.ts b/packages/files/src/Domain/Services/FileMoverInterface.ts new file mode 100644 index 000000000..e1b38238d --- /dev/null +++ b/packages/files/src/Domain/Services/FileMoverInterface.ts @@ -0,0 +1,3 @@ +export interface FileMoverInterface { + moveFile(sourcePath: string, destinationPath: string): Promise +} diff --git a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.spec.ts b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.spec.ts index fe3d74890..0b726734a 100644 --- a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.spec.ts +++ b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.spec.ts @@ -33,7 +33,7 @@ describe('CreateUploadSession', () => { expect( await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', }), ).toEqual({ success: false, @@ -44,7 +44,7 @@ describe('CreateUploadSession', () => { it('should create an upload session', async () => { await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', }) expect(fileUploader.createUploadSession).toHaveBeenCalledWith('1-2-3/2-3-4') diff --git a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.ts b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.ts index 912379ffa..17d646445 100644 --- a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.ts +++ b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.ts @@ -20,7 +20,7 @@ export class CreateUploadSession implements UseCaseInterface { try { this.logger.debug(`Creating upload session for resource: ${dto.resourceRemoteIdentifier}`) - const filePath = `${dto.userUuid}/${dto.resourceRemoteIdentifier}` + const filePath = `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}` const uploadId = await this.fileUploader.createUploadSession(filePath) diff --git a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts index f6a5597e5..177f502ea 100644 --- a/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts +++ b/packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts @@ -1,4 +1,4 @@ export type CreateUploadSessionDTO = { - userUuid: string + ownerUuid: string resourceRemoteIdentifier: string } diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts index 9c9900c88..089beb0a2 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts @@ -1,6 +1,10 @@ import 'reflect-metadata' -import { DomainEventPublisherInterface, FileUploadedEvent } from '@standardnotes/domain-events' +import { + DomainEventPublisherInterface, + FileUploadedEvent, + SharedVaultFileUploadedEvent, +} from '@standardnotes/domain-events' import { Logger } from 'winston' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' import { FileUploaderInterface } from '../../Services/FileUploaderInterface' @@ -31,6 +35,9 @@ describe('FinishUploadSession', () => { domainEventFactory = {} as jest.Mocked domainEventFactory.createFileUploadedEvent = jest.fn().mockReturnValue({} as jest.Mocked) + domainEventFactory.createSharedVaultFileUploadedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) logger = {} as jest.Mocked logger.debug = jest.fn() @@ -43,7 +50,8 @@ describe('FinishUploadSession', () => { await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', + ownerType: 'user', uploadBytesLimit: 100, uploadBytesUsed: 0, }) @@ -60,7 +68,8 @@ describe('FinishUploadSession', () => { expect( await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', + ownerType: 'user', uploadBytesLimit: 100, uploadBytesUsed: 0, }), @@ -76,7 +85,23 @@ describe('FinishUploadSession', () => { it('should finish an upload session', async () => { await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', + ownerType: 'user', + uploadBytesLimit: 100, + uploadBytesUsed: 0, + }) + + expect(fileUploader.finishUploadSession).toHaveBeenCalledWith('123', '1-2-3/2-3-4', [ + { tag: '123', chunkId: 1, chunkSize: 1 }, + ]) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should finish an upload session for a vault shared file', async () => { + await createUseCase().execute({ + resourceRemoteIdentifier: '2-3-4', + ownerUuid: '1-2-3', + ownerType: 'shared-vault', uploadBytesLimit: 100, uploadBytesUsed: 0, }) @@ -97,7 +122,8 @@ describe('FinishUploadSession', () => { expect( await createUseCase().execute({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', + ownerType: 'user', uploadBytesLimit: 100, uploadBytesUsed: 20, }), diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts index 7e7abad5e..c1897afaf 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts @@ -24,7 +24,7 @@ export class FinishUploadSession implements UseCaseInterface { try { this.logger.debug(`Finishing upload session for resource: ${dto.resourceRemoteIdentifier}`) - const filePath = `${dto.userUuid}/${dto.resourceRemoteIdentifier}` + const filePath = `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}` const uploadId = await this.uploadRepository.retrieveUploadSessionId(filePath) if (uploadId === undefined) { @@ -53,14 +53,25 @@ export class FinishUploadSession implements UseCaseInterface { await this.fileUploader.finishUploadSession(uploadId, filePath, uploadChunkResults) - await this.domainEventPublisher.publish( - this.domainEventFactory.createFileUploadedEvent({ - userUuid: dto.userUuid, - filePath: `${dto.userUuid}/${dto.resourceRemoteIdentifier}`, - fileName: dto.resourceRemoteIdentifier, - fileByteSize: totalFileSize, - }), - ) + if (dto.ownerType === 'user') { + await this.domainEventPublisher.publish( + this.domainEventFactory.createFileUploadedEvent({ + userUuid: dto.ownerUuid, + filePath: `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`, + fileName: dto.resourceRemoteIdentifier, + fileByteSize: totalFileSize, + }), + ) + } else { + await this.domainEventPublisher.publish( + this.domainEventFactory.createSharedVaultFileUploadedEvent({ + sharedVaultUuid: dto.ownerUuid, + filePath: `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`, + fileName: dto.resourceRemoteIdentifier, + fileByteSize: totalFileSize, + }), + ) + } return { success: true, diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts index 47df0dcad..9fc03b8e5 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts @@ -1,5 +1,6 @@ export type FinishUploadSessionDTO = { - userUuid: string + ownerUuid: string + ownerType: 'user' | 'shared-vault' resourceRemoteIdentifier: string uploadBytesUsed: number uploadBytesLimit: number diff --git a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.spec.ts b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.spec.ts index 0735ae12e..eb105fd2e 100644 --- a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.spec.ts +++ b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.spec.ts @@ -19,7 +19,7 @@ describe('GetFileMetadata', () => { }) it('should return the file metadata', async () => { - expect(await createUseCase().execute({ resourceRemoteIdentifier: '1-2-3', userUuid: '2-3-4' })).toEqual({ + expect(await createUseCase().execute({ resourceRemoteIdentifier: '1-2-3', ownerUuid: '2-3-4' })).toEqual({ success: true, size: 123, }) @@ -30,7 +30,7 @@ describe('GetFileMetadata', () => { throw new Error('ooops') }) - expect(await createUseCase().execute({ resourceRemoteIdentifier: '1-2-3', userUuid: '2-3-4' })).toEqual({ + expect(await createUseCase().execute({ resourceRemoteIdentifier: '1-2-3', ownerUuid: '2-3-4' })).toEqual({ success: false, message: 'Could not get file metadata.', }) diff --git a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts index 280a86a5b..ab38b1fd4 100644 --- a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts +++ b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts @@ -15,14 +15,14 @@ export class GetFileMetadata implements UseCaseInterface { async execute(dto: GetFileMetadataDTO): Promise { try { - const size = await this.fileDownloader.getFileSize(`${dto.userUuid}/${dto.resourceRemoteIdentifier}`) + const size = await this.fileDownloader.getFileSize(`${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`) return { success: true, size, } } catch (error) { - this.logger.error(`Could not get file metadata for resource: ${dto.userUuid}/${dto.resourceRemoteIdentifier}`) + this.logger.error(`Could not get file metadata for resource: ${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`) return { success: false, message: 'Could not get file metadata.', diff --git a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts index 37263cda0..de8b5632e 100644 --- a/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts +++ b/packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts @@ -1,4 +1,4 @@ export type GetFileMetadataDTO = { - userUuid: string + ownerUuid: string resourceRemoteIdentifier: string } diff --git a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.spec.ts b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.spec.ts index 72afb2dcf..c0d4d0734 100644 --- a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.spec.ts +++ b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.spec.ts @@ -21,7 +21,7 @@ describe('MarkFilesToBeRemoved', () => { }) it('should mark files for being removed', async () => { - expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ success: true }) + expect(await createUseCase().execute({ ownerUuid: '1-2-3' })).toEqual({ success: true }) expect(fileRemover.markFilesToBeRemoved).toHaveBeenCalledWith('1-2-3') }) @@ -31,7 +31,7 @@ describe('MarkFilesToBeRemoved', () => { throw new Error('Oops') }) - expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + expect(await createUseCase().execute({ ownerUuid: '1-2-3' })).toEqual({ success: false, message: 'Could not mark resources for removal', }) diff --git a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts index 8f0015472..d55ceef51 100644 --- a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts +++ b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts @@ -16,16 +16,16 @@ export class MarkFilesToBeRemoved implements UseCaseInterface { async execute(dto: MarkFilesToBeRemovedDTO): Promise { try { - this.logger.debug(`Marking files for later removal for user: ${dto.userUuid}`) + this.logger.debug(`Marking files for later removal for user: ${dto.ownerUuid}`) - const filesRemoved = await this.fileRemover.markFilesToBeRemoved(dto.userUuid) + const filesRemoved = await this.fileRemover.markFilesToBeRemoved(dto.ownerUuid) return { success: true, filesRemoved, } } catch (error) { - this.logger.error(`Could not mark resources for removal: ${dto.userUuid} - ${(error as Error).message}`) + this.logger.error(`Could not mark resources for removal: ${dto.ownerUuid} - ${(error as Error).message}`) return { success: false, diff --git a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts index ae37df330..2b61ae453 100644 --- a/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts +++ b/packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts @@ -1,3 +1,3 @@ export type MarkFilesToBeRemovedDTO = { - userUuid: string + ownerUuid: string } diff --git a/packages/files/src/Domain/UseCase/MoveFile/MoveFile.spec.ts b/packages/files/src/Domain/UseCase/MoveFile/MoveFile.spec.ts new file mode 100644 index 000000000..5febb3fdd --- /dev/null +++ b/packages/files/src/Domain/UseCase/MoveFile/MoveFile.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata' + +import { Logger } from 'winston' + +import { MoveFile } from './MoveFile' +import { FileMoverInterface } from '../../Services/FileMoverInterface' + +describe('MoveFile', () => { + let fileMover: FileMoverInterface + + let logger: Logger + + const createUseCase = () => new MoveFile(fileMover, logger) + + beforeEach(() => { + fileMover = {} as jest.Mocked + fileMover.moveFile = jest.fn().mockReturnValue(413) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.error = jest.fn() + logger.warn = jest.fn() + }) + + it('should move a file', async () => { + await createUseCase().execute({ + resourceRemoteIdentifier: '2-3-4', + fromUuid: '1-2-3', + toUuid: '4-5-6', + moveType: 'shared-vault-to-user', + }) + + expect(fileMover.moveFile).toHaveBeenCalledWith('1-2-3/2-3-4', '4-5-6/2-3-4') + }) + + it('should indicate an error if moving fails', async () => { + fileMover.moveFile = jest.fn().mockImplementation(() => { + throw new Error('oops') + }) + + const result = await createUseCase().execute({ + resourceRemoteIdentifier: '2-3-4', + fromUuid: '1-2-3', + toUuid: '4-5-6', + moveType: 'shared-vault-to-user', + }) + + expect(result.isFailed()).toEqual(true) + }) +}) diff --git a/packages/files/src/Domain/UseCase/MoveFile/MoveFile.ts b/packages/files/src/Domain/UseCase/MoveFile/MoveFile.ts new file mode 100644 index 000000000..a4e594a7c --- /dev/null +++ b/packages/files/src/Domain/UseCase/MoveFile/MoveFile.ts @@ -0,0 +1,32 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../../Bootstrap/Types' +import { FileMoverInterface } from '../../Services/FileMoverInterface' +import { MoveFileDTO } from './MoveFileDTO' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' + +@injectable() +export class MoveFile implements UseCaseInterface { + constructor( + @inject(TYPES.Files_FileMover) private fileMover: FileMoverInterface, + @inject(TYPES.Files_Logger) private logger: Logger, + ) {} + + async execute(dto: MoveFileDTO): Promise> { + try { + const srcPath = `${dto.fromUuid}/${dto.resourceRemoteIdentifier}` + const destPath = `${dto.toUuid}/${dto.resourceRemoteIdentifier}` + + this.logger.debug(`Moving file from ${srcPath} to ${destPath}`) + + await this.fileMover.moveFile(srcPath, destPath) + + return Result.ok() + } catch (error) { + this.logger.error(`Could not move resource: ${dto.resourceRemoteIdentifier} - ${(error as Error).message}`) + + return Result.fail('Could not move resource') + } + } +} diff --git a/packages/files/src/Domain/UseCase/MoveFile/MoveFileDTO.ts b/packages/files/src/Domain/UseCase/MoveFile/MoveFileDTO.ts new file mode 100644 index 000000000..dfd7ee189 --- /dev/null +++ b/packages/files/src/Domain/UseCase/MoveFile/MoveFileDTO.ts @@ -0,0 +1,8 @@ +import { SharedVaultMoveType } from '@standardnotes/security' + +export interface MoveFileDTO { + moveType: SharedVaultMoveType + fromUuid: string + toUuid: string + resourceRemoteIdentifier: string +} diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts index bdd580bb6..a5c74a90d 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts @@ -1,6 +1,10 @@ import 'reflect-metadata' -import { DomainEventPublisherInterface, FileRemovedEvent } from '@standardnotes/domain-events' +import { + DomainEventPublisherInterface, + FileRemovedEvent, + SharedVaultFileRemovedEvent, +} from '@standardnotes/domain-events' import { Logger } from 'winston' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' @@ -24,6 +28,9 @@ describe('RemoveFile', () => { domainEventFactory = {} as jest.Mocked domainEventFactory.createFileRemovedEvent = jest.fn().mockReturnValue({} as jest.Mocked) + domainEventFactory.createSharedVaultFileRemovedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) logger = {} as jest.Mocked logger.debug = jest.fn() @@ -36,25 +43,44 @@ describe('RemoveFile', () => { throw new Error('oops') }) - expect( - await createUseCase().execute({ + const result = await createUseCase().execute({ + userInput: { resourceRemoteIdentifier: '2-3-4', userUuid: '1-2-3', regularSubscriptionUuid: '3-4-5', - }), - ).toEqual({ - success: false, - message: 'Could not remove resource', + }, }) + expect(result.isFailed()).toEqual(true) expect(domainEventPublisher.publish).not.toHaveBeenCalled() }) - it('should remove a file', async () => { + it('should indicate of an error of no proper input', async () => { + const result = await createUseCase().execute({}) + expect(result.isFailed()).toEqual(true) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should remove a file for user', async () => { await createUseCase().execute({ - resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', - regularSubscriptionUuid: '3-4-5', + userInput: { + resourceRemoteIdentifier: '2-3-4', + userUuid: '1-2-3', + regularSubscriptionUuid: '3-4-5', + }, + }) + + expect(fileRemover.remove).toHaveBeenCalledWith('1-2-3/2-3-4') + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should remove a file for shared vault', async () => { + await createUseCase().execute({ + vaultInput: { + resourceRemoteIdentifier: '2-3-4', + sharedVaultUuid: '1-2-3', + }, }) expect(fileRemover.remove).toHaveBeenCalledWith('1-2-3/2-3-4') diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts index 66d802c08..1f57af696 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts @@ -5,12 +5,11 @@ import { Logger } from 'winston' import TYPES from '../../../Bootstrap/Types' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' import { FileRemoverInterface } from '../../Services/FileRemoverInterface' -import { UseCaseInterface } from '../UseCaseInterface' import { RemoveFileDTO } from './RemoveFileDTO' -import { RemoveFileResponse } from './RemoveFileResponse' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' @injectable() -export class RemoveFile implements UseCaseInterface { +export class RemoveFile implements UseCaseInterface { constructor( @inject(TYPES.Files_FileRemover) private fileRemover: FileRemoverInterface, @inject(TYPES.Files_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, @@ -18,34 +17,46 @@ export class RemoveFile implements UseCaseInterface { @inject(TYPES.Files_Logger) private logger: Logger, ) {} - async execute(dto: RemoveFileDTO): Promise { - try { - this.logger.debug(`Removing file: ${dto.resourceRemoteIdentifier}`) + async execute(dto: RemoveFileDTO): Promise> { + const resourceUuid = dto.userInput?.resourceRemoteIdentifier ?? dto.vaultInput?.resourceRemoteIdentifier - const filePath = `${dto.userUuid}/${dto.resourceRemoteIdentifier}` + const ownerUuid = dto.userInput?.userUuid ?? dto.vaultInput?.sharedVaultUuid + + try { + this.logger.debug(`Removing file: ${resourceUuid}`) + + const filePath = `${ownerUuid}/${resourceUuid}` const removedFileSize = await this.fileRemover.remove(filePath) - await this.domainEventPublisher.publish( - this.domainEventFactory.createFileRemovedEvent({ - userUuid: dto.userUuid, - filePath: `${dto.userUuid}/${dto.resourceRemoteIdentifier}`, - fileName: dto.resourceRemoteIdentifier, - fileByteSize: removedFileSize, - regularSubscriptionUuid: dto.regularSubscriptionUuid, - }), - ) - - return { - success: true, + if (dto.userInput !== undefined) { + await this.domainEventPublisher.publish( + this.domainEventFactory.createFileRemovedEvent({ + userUuid: dto.userInput.userUuid, + filePath: `${dto.userInput.userUuid}/${dto.userInput.resourceRemoteIdentifier}`, + fileName: dto.userInput.resourceRemoteIdentifier, + fileByteSize: removedFileSize, + regularSubscriptionUuid: dto.userInput.regularSubscriptionUuid, + }), + ) + } else if (dto.vaultInput !== undefined) { + await this.domainEventPublisher.publish( + this.domainEventFactory.createSharedVaultFileRemovedEvent({ + sharedVaultUuid: dto.vaultInput.sharedVaultUuid, + filePath: `${dto.vaultInput.sharedVaultUuid}/${dto.vaultInput.resourceRemoteIdentifier}`, + fileName: dto.vaultInput.resourceRemoteIdentifier, + fileByteSize: removedFileSize, + }), + ) + } else { + return Result.fail('Could not remove file') } + + return Result.ok() } catch (error) { - this.logger.error(`Could not remove resource: ${dto.resourceRemoteIdentifier} - ${(error as Error).message}`) + this.logger.error(`Could not remove resource: ${resourceUuid} - ${(error as Error).message}`) - return { - success: false, - message: 'Could not remove resource', - } + return Result.fail('Could not remove resource') } } } diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts index 84c951bd2..ac8415f2d 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts @@ -1,5 +1,11 @@ -export type RemoveFileDTO = { - userUuid: string - resourceRemoteIdentifier: string - regularSubscriptionUuid: string +export interface RemoveFileDTO { + userInput?: { + userUuid: string + resourceRemoteIdentifier: string + regularSubscriptionUuid: string + } + vaultInput?: { + sharedVaultUuid: string + resourceRemoteIdentifier: string + } } diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts deleted file mode 100644 index 2ed8da997..000000000 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type RemoveFileResponse = - | { - success: true - } - | { - success: false - message: string - } diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts index 235062fe5..f5a0719c6 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts @@ -22,7 +22,7 @@ describe('StreamDownloadFile', () => { it('should stream download file contents from S3', async () => { const result = await createUseCase().execute({ - userUuid: '2-3-4', + ownerUuid: '2-3-4', resourceRemoteIdentifier: '1-2-3', startRange: 0, endRange: 200, @@ -37,7 +37,7 @@ describe('StreamDownloadFile', () => { }) const result = await createUseCase().execute({ - userUuid: '2-3-4', + ownerUuid: '2-3-4', resourceRemoteIdentifier: '1-2-3', startRange: 0, endRange: 200, diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts index 5687664ef..7b57b0f5b 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts @@ -16,7 +16,7 @@ export class StreamDownloadFile implements UseCaseInterface { async execute(dto: StreamDownloadFileDTO): Promise { try { const readStream = await this.fileDownloader.createDownloadStream( - `${dto.userUuid}/${dto.resourceRemoteIdentifier}`, + `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`, dto.startRange, dto.endRange, ) @@ -27,7 +27,7 @@ export class StreamDownloadFile implements UseCaseInterface { } } catch (error) { this.logger.error( - `Could not create a download stream for resource: ${dto.userUuid}/${dto.resourceRemoteIdentifier}`, + `Could not create a download stream for resource: ${dto.ownerUuid}/${dto.resourceRemoteIdentifier}`, ) return { diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts index bc17fe208..664fa5bf9 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts @@ -1,5 +1,5 @@ export type StreamDownloadFileDTO = { - userUuid: string + ownerUuid: string resourceRemoteIdentifier: string startRange: number endRange: number diff --git a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.spec.ts b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.spec.ts index 56ac0db77..47ae593a5 100644 --- a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.spec.ts +++ b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.spec.ts @@ -33,7 +33,7 @@ describe('UploadFileChunk', () => { data: new Uint8Array([]), resourceRemoteIdentifier: '2-3-4', resourceUnencryptedFileSize: 123, - userUuid: '1-2-3', + ownerUuid: '1-2-3', }), ).toEqual({ success: false, @@ -52,7 +52,7 @@ describe('UploadFileChunk', () => { data: new Uint8Array([123]), resourceRemoteIdentifier: '2-3-4', resourceUnencryptedFileSize: 123, - userUuid: '1-2-3', + ownerUuid: '1-2-3', }) expect(fileUploader.uploadFileChunk).not.toHaveBeenCalled() @@ -70,7 +70,7 @@ describe('UploadFileChunk', () => { data: new Uint8Array([123]), resourceRemoteIdentifier: '2-3-4', resourceUnencryptedFileSize: 123, - userUuid: '1-2-3', + ownerUuid: '1-2-3', }), ).toEqual({ success: false, @@ -87,7 +87,7 @@ describe('UploadFileChunk', () => { data: new Uint8Array([123]), resourceRemoteIdentifier: '2-3-4', resourceUnencryptedFileSize: 123, - userUuid: '1-2-3', + ownerUuid: '1-2-3', }) expect(fileUploader.uploadFileChunk).toHaveBeenCalledWith({ diff --git a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.ts b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.ts index 0e50e0534..1ad311a5b 100644 --- a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.ts +++ b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.ts @@ -33,7 +33,7 @@ export class UploadFileChunk implements UseCaseInterface { `Starting upload file chunk ${dto.chunkId} with ${dto.data.byteLength} bytes for resource: ${dto.resourceRemoteIdentifier}`, ) - const filePath = `${dto.userUuid}/${dto.resourceRemoteIdentifier}` + const filePath = `${dto.ownerUuid}/${dto.resourceRemoteIdentifier}` const uploadId = await this.uploadRepository.retrieveUploadSessionId(filePath) if (uploadId === undefined) { diff --git a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunkDTO.ts b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunkDTO.ts index 911aed7d5..8019d1456 100644 --- a/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunkDTO.ts +++ b/packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunkDTO.ts @@ -3,7 +3,7 @@ import { ChunkId } from '../../Upload/ChunkId' export type UploadFileChunkDTO = { data: Uint8Array chunkId: ChunkId - userUuid: string + ownerUuid: string resourceRemoteIdentifier: string resourceUnencryptedFileSize: number } diff --git a/packages/files/src/Infra/FS/FSFileMover.ts b/packages/files/src/Infra/FS/FSFileMover.ts new file mode 100644 index 000000000..1413501c6 --- /dev/null +++ b/packages/files/src/Infra/FS/FSFileMover.ts @@ -0,0 +1,22 @@ +import { inject, injectable } from 'inversify' +import { promises as fsPromises } from 'fs' +import * as path from 'path' + +import { FileMoverInterface } from '../../Domain/Services/FileMoverInterface' +import TYPES from '../../Bootstrap/Types' + +@injectable() +export class FSFileMover implements FileMoverInterface { + constructor(@inject(TYPES.Files_FILE_UPLOAD_PATH) private fileUploadPath: string) {} + + async moveFile(sourcePath: string, destinationPath: string): Promise { + const sourceFullPath = `${this.fileUploadPath}/${sourcePath}` + const destinationFullPath = `${this.fileUploadPath}/${destinationPath}` + + const destinationDir = path.dirname(destinationFullPath) + + await fsPromises.mkdir(destinationDir, { recursive: true }) + + await fsPromises.rename(sourceFullPath, destinationFullPath) + } +} diff --git a/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.spec.ts b/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.spec.ts index b2c83ef5c..14ce3c1b2 100644 --- a/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.spec.ts +++ b/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.spec.ts @@ -13,6 +13,7 @@ import { results } from 'inversify-express-utils' import { RemoveFile } from '../../Domain/UseCase/RemoveFile/RemoveFile' import { ValetTokenOperation } from '@standardnotes/security' import { BadRequestErrorMessageResult } from 'inversify-express-utils/lib/results' +import { Result } from '@standardnotes/domain-core' describe('InversifyExpressFilesController', () => { let uploadFileChunk: UploadFileChunk @@ -57,7 +58,7 @@ describe('InversifyExpressFilesController', () => { getFileMetadata.execute = jest.fn().mockReturnValue({ success: true, size: 555_555 }) removeFile = {} as jest.Mocked - removeFile.execute = jest.fn().mockReturnValue({ success: true }) + removeFile.execute = jest.fn().mockReturnValue(Result.ok()) request = { body: {}, @@ -202,7 +203,7 @@ describe('InversifyExpressFilesController', () => { expect(createUploadSession.execute).toHaveBeenCalledWith({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerUuid: '1-2-3', }) }) @@ -232,7 +233,8 @@ describe('InversifyExpressFilesController', () => { expect(finishUploadSession.execute).toHaveBeenCalledWith({ resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + ownerType: 'user', + ownerUuid: '1-2-3', }) }) @@ -261,15 +263,17 @@ describe('InversifyExpressFilesController', () => { await createController().remove(request, response) expect(removeFile.execute).toHaveBeenCalledWith({ - resourceRemoteIdentifier: '2-3-4', - userUuid: '1-2-3', + userInput: { + resourceRemoteIdentifier: '2-3-4', + userUuid: '1-2-3', + }, }) }) it('should return bad request if file removal could not be completed', async () => { response.locals.permittedOperation = ValetTokenOperation.Delete - removeFile.execute = jest.fn().mockReturnValue({ success: false }) + removeFile.execute = jest.fn().mockReturnValue(Result.fail('error')) const httpResponse = await createController().remove(request, response) const result = await httpResponse.executeAsync() @@ -299,7 +303,7 @@ describe('InversifyExpressFilesController', () => { data: Buffer.from([123]), resourceRemoteIdentifier: '2-3-4', resourceUnencryptedFileSize: 123, - userUuid: '1-2-3', + ownerUuid: '1-2-3', }) }) diff --git a/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.ts b/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.ts index 722acf5a2..c6cce7cf8 100644 --- a/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.ts +++ b/packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.ts @@ -35,7 +35,7 @@ export class InversifyExpressFilesController extends BaseHttpController { } const result = await this.createUploadSession.execute({ - userUuid: response.locals.userUuid, + ownerUuid: response.locals.userUuid, resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, }) @@ -61,7 +61,7 @@ export class InversifyExpressFilesController extends BaseHttpController { } const result = await this.uploadFileChunk.execute({ - userUuid: response.locals.userUuid, + ownerUuid: response.locals.userUuid, resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, resourceUnencryptedFileSize: response.locals.permittedResources[0].unencryptedFileSize, chunkId, @@ -85,7 +85,8 @@ export class InversifyExpressFilesController extends BaseHttpController { } const result = await this.finishUploadSession.execute({ - userUuid: response.locals.userUuid, + ownerUuid: response.locals.userUuid, + ownerType: 'user', resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, uploadBytesLimit: response.locals.uploadBytesLimit, uploadBytesUsed: response.locals.uploadBytesUsed, @@ -108,13 +109,15 @@ export class InversifyExpressFilesController extends BaseHttpController { } const result = await this.removeFile.execute({ - userUuid: response.locals.userUuid, - resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, - regularSubscriptionUuid: response.locals.regularSubscriptionUuid, + userInput: { + userUuid: response.locals.userUuid, + resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, + regularSubscriptionUuid: response.locals.regularSubscriptionUuid, + }, }) - if (!result.success) { - return this.badRequest(result.message) + if (result.isFailed()) { + return this.badRequest(result.getError()) } return this.json({ success: true, message: 'File removed successfully' }) @@ -140,7 +143,7 @@ export class InversifyExpressFilesController extends BaseHttpController { } const fileMetadata = await this.getFileMetadata.execute({ - userUuid: response.locals.userUuid, + ownerUuid: response.locals.userUuid, resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, }) @@ -161,7 +164,7 @@ export class InversifyExpressFilesController extends BaseHttpController { response.writeHead(206, headers) const result = await this.streamDownloadFile.execute({ - userUuid: response.locals.userUuid, + ownerUuid: response.locals.userUuid, resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, startRange, endRange, diff --git a/packages/files/src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController.ts b/packages/files/src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController.ts new file mode 100644 index 000000000..5ec31549e --- /dev/null +++ b/packages/files/src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController.ts @@ -0,0 +1,215 @@ +import { BaseHttpController, controller, httpDelete, httpGet, httpPost, results } from 'inversify-express-utils' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { Writable } from 'stream' +import { SharedVaultValetTokenData, ValetTokenOperation } from '@standardnotes/security' + +import TYPES from '../../Bootstrap/Types' +import { CreateUploadSession } from '../../Domain/UseCase/CreateUploadSession/CreateUploadSession' +import { FinishUploadSession } from '../../Domain/UseCase/FinishUploadSession/FinishUploadSession' +import { GetFileMetadata } from '../../Domain/UseCase/GetFileMetadata/GetFileMetadata' +import { MoveFile } from '../../Domain/UseCase/MoveFile/MoveFile' +import { RemoveFile } from '../../Domain/UseCase/RemoveFile/RemoveFile' +import { StreamDownloadFile } from '../../Domain/UseCase/StreamDownloadFile/StreamDownloadFile' +import { UploadFileChunk } from '../../Domain/UseCase/UploadFileChunk/UploadFileChunk' + +@controller('/v1/shared-vault/files', TYPES.Files_SharedVaultValetTokenAuthMiddleware) +export class InversifyExpressSharedVaultFilesController extends BaseHttpController { + constructor( + @inject(TYPES.Files_UploadFileChunk) private uploadFileChunk: UploadFileChunk, + @inject(TYPES.Files_CreateUploadSession) private createUploadSession: CreateUploadSession, + @inject(TYPES.Files_FinishUploadSession) private finishUploadSession: FinishUploadSession, + @inject(TYPES.Files_StreamDownloadFile) private streamDownloadFile: StreamDownloadFile, + @inject(TYPES.Files_GetFileMetadata) private getFileMetadata: GetFileMetadata, + @inject(TYPES.Files_RemoveFile) private removeFile: RemoveFile, + @inject(TYPES.Files_MoveFile) private moveFile: MoveFile, + @inject(TYPES.Files_MAX_CHUNK_BYTES) private maxChunkBytes: number, + ) { + super() + } + + @httpPost('/move') + async moveFileRequest( + _request: Request, + response: Response, + ): Promise { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Move) { + return this.badRequest('Not permitted for this operation') + } + + const moveOperation = locals.moveOperation + if (!moveOperation) { + return this.badRequest('Missing move operation data') + } + + const result = await this.moveFile.execute({ + moveType: moveOperation.type, + fromUuid: moveOperation.fromUuid, + toUuid: moveOperation.toUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + }) + + if (result.isFailed()) { + return this.badRequest(result.getError()) + } + + return this.json({ success: true }) + } + + @httpPost('/upload/create-session') + async startUpload( + _request: Request, + response: Response, + ): Promise { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Write) { + return this.badRequest('Not permitted for this operation') + } + + const result = await this.createUploadSession.execute({ + ownerUuid: locals.sharedVaultUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + }) + + if (!result.success) { + return this.badRequest(result.message) + } + + return this.json({ success: true, uploadId: result.uploadId }) + } + + @httpPost('/upload/chunk') + async uploadChunk( + request: Request, + response: Response, + ): Promise { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Write) { + return this.badRequest('Not permitted for this operation') + } + + const chunkId = +(request.headers['x-chunk-id'] as string) + if (!chunkId) { + return this.badRequest('Missing x-chunk-id header in request.') + } + + const result = await this.uploadFileChunk.execute({ + ownerUuid: locals.sharedVaultUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + resourceUnencryptedFileSize: locals.unencryptedFileSize as number, + chunkId, + data: request.body, + }) + + if (!result.success) { + return this.badRequest(result.message) + } + + return this.json({ success: true, message: 'Chunk uploaded successfully' }) + } + + @httpPost('/upload/close-session') + public async finishUpload( + _request: Request, + response: Response, + ): Promise { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Write) { + return this.badRequest('Not permitted for this operation') + } + + const result = await this.finishUploadSession.execute({ + ownerUuid: locals.sharedVaultUuid, + ownerType: 'shared-vault', + resourceRemoteIdentifier: locals.remoteIdentifier, + uploadBytesLimit: locals.uploadBytesLimit, + uploadBytesUsed: locals.uploadBytesUsed, + }) + + if (!result.success) { + return this.badRequest(result.message) + } + + return this.json({ success: true, message: 'File uploaded successfully' }) + } + + @httpDelete('/') + async remove( + _request: Request, + response: Response, + ): Promise { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Delete) { + return this.badRequest('Not permitted for this operation') + } + + const result = await this.removeFile.execute({ + vaultInput: { + sharedVaultUuid: locals.sharedVaultUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + }, + }) + + if (result.isFailed()) { + return this.badRequest(result.getError()) + } + + return this.json({ success: true, message: 'File removed successfully' }) + } + + @httpGet('/') + async download( + request: Request, + response: Response, + ): Promise Writable)> { + const locals = response.locals as SharedVaultValetTokenData + if (locals.permittedOperation !== ValetTokenOperation.Read) { + return this.badRequest('Not permitted for this operation') + } + + const range = request.headers['range'] + if (!range) { + return this.badRequest('File download requires range header to be set.') + } + + let chunkSize = +(request.headers['x-chunk-size'] as string) + if (!chunkSize || chunkSize > this.maxChunkBytes) { + chunkSize = this.maxChunkBytes + } + + const fileMetadata = await this.getFileMetadata.execute({ + ownerUuid: locals.sharedVaultUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + }) + + if (!fileMetadata.success) { + return this.badRequest(fileMetadata.message) + } + + const startRange = Number(range.replace(/\D/g, '')) + const endRange = Math.min(startRange + chunkSize - 1, fileMetadata.size - 1) + + const headers = { + 'Content-Range': `bytes ${startRange}-${endRange}/${fileMetadata.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': endRange - startRange + 1, + 'Content-Type': 'application/octet-stream', + } + + response.writeHead(206, headers) + + const result = await this.streamDownloadFile.execute({ + ownerUuid: locals.sharedVaultUuid, + resourceRemoteIdentifier: locals.remoteIdentifier, + startRange, + endRange, + }) + + if (!result.success) { + return this.badRequest(result.message) + } + + return () => result.readStream.pipe(response) + } +} diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts new file mode 100644 index 000000000..da3254391 --- /dev/null +++ b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts @@ -0,0 +1,81 @@ +import { SharedVaultValetTokenData, TokenDecoderInterface } from '@standardnotes/security' +import { Uuid } from '@standardnotes/domain-core' +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' + +import TYPES from '../../../Bootstrap/Types' + +@injectable() +export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.Files_ValetTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Files_Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + const valetToken = request.headers['x-valet-token'] || request.body.valetToken || request.query.valetToken + if (!valetToken) { + this.logger.debug('SharedVaultValetTokenAuthMiddleware missing valet token.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid valet token.', + }, + }) + + return + } + + const valetTokenData = this.tokenDecoder.decodeToken(valetToken) + + if (valetTokenData === undefined) { + this.logger.debug('SharedVaultValetTokenAuthMiddleware authentication failure.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid valet token.', + }, + }) + + return + } + + const resourceUuidOrError = Uuid.create(valetTokenData.remoteIdentifier) + if (resourceUuidOrError.isFailed()) { + this.logger.debug('Invalid remote resource identifier in token.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid valet token.', + }, + }) + + return + } + + const whitelistedData: SharedVaultValetTokenData = { + sharedVaultUuid: valetTokenData.sharedVaultUuid, + remoteIdentifier: valetTokenData.remoteIdentifier, + permittedOperation: valetTokenData.permittedOperation, + uploadBytesUsed: valetTokenData.uploadBytesUsed, + uploadBytesLimit: valetTokenData.uploadBytesLimit, + unencryptedFileSize: valetTokenData.unencryptedFileSize, + moveOperation: valetTokenData.moveOperation, + } + + Object.assign(response.locals, whitelistedData) + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/files/src/Infra/S3/S3FileMover.ts b/packages/files/src/Infra/S3/S3FileMover.ts new file mode 100644 index 000000000..18aac7aa5 --- /dev/null +++ b/packages/files/src/Infra/S3/S3FileMover.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from 'inversify' +import { CopyObjectCommand, DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import TYPES from '../../Bootstrap/Types' +import { FileMoverInterface } from '../../Domain/Services/FileMoverInterface' + +@injectable() +export class S3FileMover implements FileMoverInterface { + constructor( + @inject(TYPES.Files_S3) private s3Client: S3Client, + @inject(TYPES.Files_S3_BUCKET_NAME) private s3BucketName: string, + ) {} + + async moveFile(sourcePath: string, destinationPath: string): Promise { + await this.s3Client.send( + new CopyObjectCommand({ + Bucket: this.s3BucketName, + CopySource: `${this.s3BucketName}/${sourcePath}`, + Key: destinationPath, + }), + ) + + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.s3BucketName, + Key: sourcePath, + }), + ) + } +} diff --git a/packages/security/src/Domain/Token/CrossServiceTokenData.ts b/packages/security/src/Domain/Token/CrossServiceTokenData.ts index e29820a10..eeea9b4f6 100644 --- a/packages/security/src/Domain/Token/CrossServiceTokenData.ts +++ b/packages/security/src/Domain/Token/CrossServiceTokenData.ts @@ -4,6 +4,7 @@ export type CrossServiceTokenData = { user: { uuid: string email: string + publicKey?: string } roles: Array session?: { diff --git a/packages/security/src/Domain/Token/SharedVaultMoveType.ts b/packages/security/src/Domain/Token/SharedVaultMoveType.ts new file mode 100644 index 000000000..07b24b13d --- /dev/null +++ b/packages/security/src/Domain/Token/SharedVaultMoveType.ts @@ -0,0 +1 @@ +export type SharedVaultMoveType = 'shared-vault-to-user' | 'user-to-shared-vault' | 'shared-vault-to-shared-vault' diff --git a/packages/security/src/Domain/Token/SharedVaultValetTokenData.ts b/packages/security/src/Domain/Token/SharedVaultValetTokenData.ts new file mode 100644 index 000000000..bd383eb1e --- /dev/null +++ b/packages/security/src/Domain/Token/SharedVaultValetTokenData.ts @@ -0,0 +1,16 @@ +import { ValetTokenOperation } from './ValetTokenOperation' +import { SharedVaultMoveType } from './SharedVaultMoveType' + +export interface SharedVaultValetTokenData { + sharedVaultUuid: string + permittedOperation: ValetTokenOperation + remoteIdentifier: string + unencryptedFileSize?: number + uploadBytesUsed: number + uploadBytesLimit: number + moveOperation?: { + type: SharedVaultMoveType + fromUuid: string + toUuid: string + } +} diff --git a/packages/security/src/Domain/Token/ValetTokenOperation.ts b/packages/security/src/Domain/Token/ValetTokenOperation.ts index e785e3820..6136ce7bd 100644 --- a/packages/security/src/Domain/Token/ValetTokenOperation.ts +++ b/packages/security/src/Domain/Token/ValetTokenOperation.ts @@ -2,4 +2,5 @@ export enum ValetTokenOperation { Read = 'read', Write = 'write', Delete = 'delete', + Move = 'move', } diff --git a/packages/security/src/Domain/index.ts b/packages/security/src/Domain/index.ts index 8c1f17e86..22ac79881 100644 --- a/packages/security/src/Domain/index.ts +++ b/packages/security/src/Domain/index.ts @@ -10,6 +10,8 @@ export * from './Token/CrossServiceTokenData' export * from './Token/OfflineFeaturesTokenData' export * from './Token/OfflineUserTokenData' export * from './Token/SessionTokenData' +export * from './Token/SharedVaultMoveType' +export * from './Token/SharedVaultValetTokenData' export * from './Token/ValetTokenData' export * from './Token/ValetTokenOperation' export * from './Token/WebSocketConnectionToken'