Browse Source

feat: shared vaults functionality in api-gateway,auth,files,common,security,domain-events. (#629)

Co-authored-by: Mo <mo@standardnotes.com>
Karol Sójko 2 years ago
parent
commit
fa7fbe26e7
82 changed files with 942 additions and 219 deletions
  1. 25 22
      packages/api-gateway/bin/server.ts
  2. 2 0
      packages/api-gateway/src/Controller/index.ts
  3. 17 0
      packages/api-gateway/src/Controller/v1/AsymmetricMessagesController.ts
  4. 17 0
      packages/api-gateway/src/Controller/v1/SharedVaultsController.ts
  5. 2 1
      packages/auth/package.json
  6. 5 0
      packages/auth/src/Controller/AuthController.ts
  7. 2 6
      packages/auth/src/Domain/Auth/AuthResponse.ts
  8. 2 6
      packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts
  9. 3 3
      packages/auth/src/Domain/Setting/SettingService.spec.ts
  10. 3 2
      packages/auth/src/Domain/Setting/SettingService.ts
  11. 1 1
      packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts
  12. 10 8
      packages/auth/src/Domain/Setting/SettingsAssociationService.ts
  13. 1 1
      packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts
  14. 2 1
      packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts
  15. 2 2
      packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts
  16. 2 0
      packages/auth/src/Domain/UseCase/RegisterDTO.ts
  17. 0 8
      packages/auth/src/Domain/UseCase/UpdateUser.spec.ts
  18. 5 13
      packages/auth/src/Domain/UseCase/UpdateUser.ts
  19. 1 12
      packages/auth/src/Domain/UseCase/UpdateUserDTO.ts
  20. 2 2
      packages/auth/src/Domain/User/User.spec.ts
  21. 1 1
      packages/auth/src/Domain/User/User.ts
  22. 7 0
      packages/auth/src/Domain/ValetToken/CreateValetTokenPayload.ts
  23. 0 9
      packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts
  24. 3 2
      packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerValetTokenController.ts
  25. 0 4
      packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressUsersController.spec.ts
  26. 7 0
      packages/auth/src/Projection/SimpleUserProjection.ts
  27. 2 1
      packages/auth/src/Projection/UserProjector.ts
  28. 0 1
      packages/common/src/Domain/Protocol/ProtocolVersion.ts
  29. 1 1
      packages/domain-events/src/Domain/Event/DomainEventInterface.ts
  30. 7 0
      packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEvent.ts
  31. 6 0
      packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts
  32. 7 0
      packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEvent.ts
  33. 6 0
      packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts
  34. 4 0
      packages/domain-events/src/Domain/index.ts
  35. 1 0
      packages/event-store/src/Bootstrap/Container.ts
  36. 1 0
      packages/files/bin/server.ts
  37. 11 0
      packages/files/src/Bootstrap/Container.ts
  38. 3 0
      packages/files/src/Bootstrap/Types.ts
  39. 54 0
      packages/files/src/Domain/Event/DomainEventFactory.spec.ts
  40. 47 1
      packages/files/src/Domain/Event/DomainEventFactory.ts
  41. 18 1
      packages/files/src/Domain/Event/DomainEventFactoryInterface.ts
  42. 2 2
      packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts
  43. 1 1
      packages/files/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts
  44. 2 2
      packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.spec.ts
  45. 1 1
      packages/files/src/Domain/Handler/SharedSubscriptionInvitationCanceledEventHandler.ts
  46. 3 0
      packages/files/src/Domain/Services/FileMoverInterface.ts
  47. 2 2
      packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.spec.ts
  48. 1 1
      packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSession.ts
  49. 1 1
      packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts
  50. 31 5
      packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts
  51. 20 9
      packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts
  52. 2 1
      packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts
  53. 2 2
      packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.spec.ts
  54. 2 2
      packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts
  55. 1 1
      packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts
  56. 2 2
      packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.spec.ts
  57. 3 3
      packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts
  58. 1 1
      packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts
  59. 50 0
      packages/files/src/Domain/UseCase/MoveFile/MoveFile.spec.ts
  60. 32 0
      packages/files/src/Domain/UseCase/MoveFile/MoveFile.ts
  61. 8 0
      packages/files/src/Domain/UseCase/MoveFile/MoveFileDTO.ts
  62. 37 11
      packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts
  63. 34 23
      packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts
  64. 10 4
      packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts
  65. 0 8
      packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts
  66. 2 2
      packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts
  67. 2 2
      packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts
  68. 1 1
      packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts
  69. 4 4
      packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.spec.ts
  70. 1 1
      packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunk.ts
  71. 1 1
      packages/files/src/Domain/UseCase/UploadFileChunk/UploadFileChunkDTO.ts
  72. 22 0
      packages/files/src/Infra/FS/FSFileMover.ts
  73. 11 7
      packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.spec.ts
  74. 13 10
      packages/files/src/Infra/InversifyExpress/InversifyExpressFilesController.ts
  75. 215 0
      packages/files/src/Infra/InversifyExpress/InversifyExpressSharedVaultFilesController.ts
  76. 81 0
      packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts
  77. 30 0
      packages/files/src/Infra/S3/S3FileMover.ts
  78. 1 0
      packages/security/src/Domain/Token/CrossServiceTokenData.ts
  79. 1 0
      packages/security/src/Domain/Token/SharedVaultMoveType.ts
  80. 16 0
      packages/security/src/Domain/Token/SharedVaultValetTokenData.ts
  81. 1 0
      packages/security/src/Domain/Token/ValetTokenOperation.ts
  82. 2 0
      packages/security/src/Domain/index.ts

+ 25 - 22
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({

+ 2 - 0
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'

+ 17 - 0
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<void> {
+    await this.serviceProxy.callSyncingServer(request, response, request.path.replace('/v1/', ''), request.body)
+  }
+}

+ 17 - 0
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<void> {
+    await this.serviceProxy.callSyncingServer(request, response, request.path.replace('/v1/', ''), request.body)
+  }
+}

+ 2 - 1
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",

+ 5 - 0
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) {

+ 2 - 6
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
 }

+ 2 - 6
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,
     }
   }
 

+ 3 - 3
packages/auth/src/Domain/Setting/SettingService.spec.ts

@@ -37,7 +37,7 @@ describe('SettingService', () => {
     user = {
       uuid: '4-5-6',
     } as jest.Mocked<User>
-    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)
 

+ 3 - 2
packages/auth/src/Domain/Setting/SettingService.ts

@@ -28,8 +28,9 @@ export class SettingService implements SettingServiceInterface {
 
   async applyDefaultSettingsUponRegistration(user: User): Promise<void> {
     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()) {

+ 1 - 1
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<string, SettingDescription>).keys()]
     expect(flatSettings).toEqual(['MUTE_MARKETING_EMAILS', 'LOG_SESSION_USER_AGENT'])
 

+ 10 - 8
packages/auth/src/Domain/Setting/SettingsAssociationService.ts

@@ -66,7 +66,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
     ],
   ])
 
-  private readonly vaultAccountDefaultSettingsOverwrites = new Map<string, SettingDescription>([
+  private readonly privateUsernameAccountDefaultSettingsOverwrites = new Map<string, SettingDescription>([
     [
       SettingName.NAMES.LogSessionUserAgent,
       {
@@ -114,16 +114,18 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
     return this.defaultSettings
   }
 
-  getDefaultSettingsAndValuesForNewVaultAccount(): Map<string, SettingDescription> {
-    const defaultVaultSettings = new Map(this.defaultSettings)
+  getDefaultSettingsAndValuesForNewPrivateUsernameAccount(): Map<string, SettingDescription> {
+    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
   }
 }

+ 1 - 1
packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts

@@ -6,7 +6,7 @@ import { SettingDescription } from './SettingDescription'
 
 export interface SettingsAssociationServiceInterface {
   getDefaultSettingsAndValuesForNewUser(): Map<string, SettingDescription>
-  getDefaultSettingsAndValuesForNewVaultAccount(): Map<string, SettingDescription>
+  getDefaultSettingsAndValuesForNewPrivateUsernameAccount(): Map<string, SettingDescription>
   getPermissionAssociatedWithSetting(settingName: SettingName): PermissionName | undefined
   getEncryptionVersionForSetting(settingName: SettingName): EncryptionVersion
   getSensitivityForSetting(settingName: SettingName): boolean

+ 2 - 1
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 {

+ 2 - 2
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('@')
   }
 }

+ 2 - 0
packages/auth/src/Domain/UseCase/RegisterDTO.ts

@@ -10,4 +10,6 @@ export type RegisterDTO = {
   kpOrigination?: string
   kpCreated?: string
   version?: string
+  publicKey?: string
+  signingPublicKey?: string
 }

+ 0 - 8
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),
     })
   })

+ 5 - 13
packages/auth/src/Domain/UseCase/UpdateUser.ts

@@ -17,25 +17,17 @@ export class UpdateUser implements UseCaseInterface {
   ) {}
 
   async execute(dto: UpdateUserDTO): Promise<UpdateUserResponse> {
-    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,

+ 1 - 12
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
 }

+ 2 - 2
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()
   })
 })

+ 1 - 1
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('@')
   }
 }

+ 7 - 0
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
+  }>
+}

+ 0 - 9
packages/auth/src/Infra/InversifyExpressUtils/HomeServer/HomeServerUsersController.ts

@@ -60,15 +60,6 @@ export class HomeServerUsersController extends BaseHttpController {
       user: response.locals.user,
       updatedWithUserAgent: <string>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) {

+ 3 - 2
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) {

+ 0 - 4
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',

+ 7 - 0
packages/auth/src/Projection/SimpleUserProjection.ts

@@ -0,0 +1,7 @@
+export type SimpleUserProjection = {
+  uuid: string
+  email: string
+  protocolVersion: string
+  publicKey?: string
+  signingPublicKey?: string
+}

+ 2 - 1
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<User> {
-  projectSimple(user: User): Record<string, unknown> {
+  projectSimple(user: User): SimpleUserProjection {
     return {
       uuid: user.uuid,
       email: user.email,

+ 0 - 1
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

+ 1 - 1
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

+ 7 - 0
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
+}

+ 6 - 0
packages/domain-events/src/Domain/Event/SharedVaultFileRemovedEventPayload.ts

@@ -0,0 +1,6 @@
+export interface SharedVaultFileRemovedEventPayload {
+  sharedVaultUuid: string
+  fileByteSize: number
+  filePath: string
+  fileName: string
+}

+ 7 - 0
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
+}

+ 6 - 0
packages/domain-events/src/Domain/Event/SharedVaultFileUploadedEventPayload.ts

@@ -0,0 +1,6 @@
+export interface SharedVaultFileUploadedEventPayload {
+  sharedVaultUuid: string
+  fileByteSize: number
+  filePath: string
+  fileName: string
+}

+ 4 - 0
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'

+ 1 - 0
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)],

+ 1 - 0
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'

+ 11 - 0
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<FileDownloaderInterface>(TYPES.Files_FileDownloader).to(S3FileDownloader)
       container.bind<FileUploaderInterface>(TYPES.Files_FileUploader).to(S3FileUploader)
       container.bind<FileRemoverInterface>(TYPES.Files_FileRemover).to(S3FileRemover)
+      container.bind<FileMoverInterface>(TYPES.Files_FileMover).to(S3FileMover)
     } else {
       container.bind<FileDownloaderInterface>(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<FileRemoverInterface>(TYPES.Files_FileRemover).to(FSFileRemover)
+      container.bind<FileMoverInterface>(TYPES.Files_FileMover).to(FSFileMover)
     }
 
     // use cases
@@ -194,10 +201,14 @@ export class ContainerConfigLoader {
     container.bind<FinishUploadSession>(TYPES.Files_FinishUploadSession).to(FinishUploadSession)
     container.bind<GetFileMetadata>(TYPES.Files_GetFileMetadata).to(GetFileMetadata)
     container.bind<RemoveFile>(TYPES.Files_RemoveFile).to(RemoveFile)
+    container.bind<MoveFile>(TYPES.Files_MoveFile).to(MoveFile)
     container.bind<MarkFilesToBeRemoved>(TYPES.Files_MarkFilesToBeRemoved).to(MarkFilesToBeRemoved)
 
     // middleware
     container.bind<ValetTokenAuthMiddleware>(TYPES.Files_ValetTokenAuthMiddleware).to(ValetTokenAuthMiddleware)
+    container
+      .bind<SharedVaultValetTokenAuthMiddleware>(TYPES.Files_SharedVaultValetTokenAuthMiddleware)
+      .to(SharedVaultValetTokenAuthMiddleware)
 
     // services
     container

+ 3 - 0
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'),

+ 54 - 0
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({

+ 47 - 1
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,
+    }
+  }
 }

+ 18 - 1
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
 }

+ 2 - 2
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()
   })

+ 1 - 1
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) {

+ 2 - 2
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()
   })

+ 1 - 1
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) {

+ 3 - 0
packages/files/src/Domain/Services/FileMoverInterface.ts

@@ -0,0 +1,3 @@
+export interface FileMoverInterface {
+  moveFile(sourcePath: string, destinationPath: string): Promise<void>
+}

+ 2 - 2
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')

+ 1 - 1
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)
 

+ 1 - 1
packages/files/src/Domain/UseCase/CreateUploadSession/CreateUploadSessionDTO.ts

@@ -1,4 +1,4 @@
 export type CreateUploadSessionDTO = {
-  userUuid: string
+  ownerUuid: string
   resourceRemoteIdentifier: string
 }

+ 31 - 5
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<DomainEventFactoryInterface>
     domainEventFactory.createFileUploadedEvent = jest.fn().mockReturnValue({} as jest.Mocked<FileUploadedEvent>)
+    domainEventFactory.createSharedVaultFileUploadedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<SharedVaultFileUploadedEvent>)
 
     logger = {} as jest.Mocked<Logger>
     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,
       }),

+ 20 - 9
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,

+ 2 - 1
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

+ 2 - 2
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.',
     })

+ 2 - 2
packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadata.ts

@@ -15,14 +15,14 @@ export class GetFileMetadata implements UseCaseInterface {
 
   async execute(dto: GetFileMetadataDTO): Promise<GetFileMetadataResponse> {
     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.',

+ 1 - 1
packages/files/src/Domain/UseCase/GetFileMetadata/GetFileMetadataDTO.ts

@@ -1,4 +1,4 @@
 export type GetFileMetadataDTO = {
-  userUuid: string
+  ownerUuid: string
   resourceRemoteIdentifier: string
 }

+ 2 - 2
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',
     })

+ 3 - 3
packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemoved.ts

@@ -16,16 +16,16 @@ export class MarkFilesToBeRemoved implements UseCaseInterface {
 
   async execute(dto: MarkFilesToBeRemovedDTO): Promise<MarkFilesToBeRemovedResponse> {
     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,

+ 1 - 1
packages/files/src/Domain/UseCase/MarkFilesToBeRemoved/MarkFilesToBeRemovedDTO.ts

@@ -1,3 +1,3 @@
 export type MarkFilesToBeRemovedDTO = {
-  userUuid: string
+  ownerUuid: string
 }

+ 50 - 0
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<FileMoverInterface>
+    fileMover.moveFile = jest.fn().mockReturnValue(413)
+
+    logger = {} as jest.Mocked<Logger>
+    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)
+  })
+})

+ 32 - 0
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<boolean> {
+  constructor(
+    @inject(TYPES.Files_FileMover) private fileMover: FileMoverInterface,
+    @inject(TYPES.Files_Logger) private logger: Logger,
+  ) {}
+
+  async execute(dto: MoveFileDTO): Promise<Result<boolean>> {
+    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')
+    }
+  }
+}

+ 8 - 0
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
+}

+ 37 - 11
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<DomainEventFactoryInterface>
     domainEventFactory.createFileRemovedEvent = jest.fn().mockReturnValue({} as jest.Mocked<FileRemovedEvent>)
+    domainEventFactory.createSharedVaultFileRemovedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<SharedVaultFileRemovedEvent>)
 
     logger = {} as jest.Mocked<Logger>
     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')

+ 34 - 23
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<boolean> {
   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<RemoveFileResponse> {
+  async execute(dto: RemoveFileDTO): Promise<Result<boolean>> {
+    const resourceUuid = dto.userInput?.resourceRemoteIdentifier ?? dto.vaultInput?.resourceRemoteIdentifier
+
+    const ownerUuid = dto.userInput?.userUuid ?? dto.vaultInput?.sharedVaultUuid
+
     try {
-      this.logger.debug(`Removing file: ${dto.resourceRemoteIdentifier}`)
+      this.logger.debug(`Removing file: ${resourceUuid}`)
 
-      const filePath = `${dto.userUuid}/${dto.resourceRemoteIdentifier}`
+      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')
     }
   }
 }

+ 10 - 4
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
+  }
 }

+ 0 - 8
packages/files/src/Domain/UseCase/RemoveFile/RemoveFileResponse.ts

@@ -1,8 +0,0 @@
-export type RemoveFileResponse =
-  | {
-      success: true
-    }
-  | {
-      success: false
-      message: string
-    }

+ 2 - 2
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,

+ 2 - 2
packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts

@@ -16,7 +16,7 @@ export class StreamDownloadFile implements UseCaseInterface {
   async execute(dto: StreamDownloadFileDTO): Promise<StreamDownloadFileResponse> {
     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 {

+ 1 - 1
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

+ 4 - 4
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({

+ 1 - 1
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) {

+ 1 - 1
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
 }

+ 22 - 0
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<void> {
+    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)
+  }
+}

+ 11 - 7
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>
-    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',
     })
   })
 

+ 13 - 10
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,

+ 215 - 0
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<results.BadRequestErrorMessageResult | results.JsonResult> {
+    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<results.BadRequestErrorMessageResult | results.JsonResult> {
+    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<results.BadRequestErrorMessageResult | results.JsonResult> {
+    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<results.BadRequestErrorMessageResult | results.JsonResult> {
+    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<results.BadRequestErrorMessageResult | results.JsonResult> {
+    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<results.BadRequestErrorMessageResult | (() => 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)
+  }
+}

+ 81 - 0
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<SharedVaultValetTokenData>,
+    @inject(TYPES.Files_Logger) private logger: Logger,
+  ) {
+    super()
+  }
+
+  async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
+    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)
+    }
+  }
+}

+ 30 - 0
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<void> {
+    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,
+      }),
+    )
+  }
+}

+ 1 - 0
packages/security/src/Domain/Token/CrossServiceTokenData.ts

@@ -4,6 +4,7 @@ export type CrossServiceTokenData = {
   user: {
     uuid: string
     email: string
+    publicKey?: string
   }
   roles: Array<Role>
   session?: {

+ 1 - 0
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'

+ 16 - 0
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
+  }
+}

+ 1 - 0
packages/security/src/Domain/Token/ValetTokenOperation.ts

@@ -2,4 +2,5 @@ export enum ValetTokenOperation {
   Read = 'read',
   Write = 'write',
   Delete = 'delete',
+  Move = 'move',
 }

+ 2 - 0
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'