Prechádzať zdrojové kódy

Merge 484d5a938e0b065f96672ec16908cab5fcd35291 into dbb0e4a9746c022126e719ddbcd31cec9fca12aa

Karol Sójko 1 rok pred
rodič
commit
ec509c7db2

+ 18 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -169,8 +169,10 @@ import { DummyMetricStore } from '../Infra/Dummy/DummyMetricStore'
 import { CheckForTrafficAbuse } from '../Domain/UseCase/Syncing/CheckForTrafficAbuse/CheckForTrafficAbuse'
 import { FixContentSizes } from '../Domain/UseCase/Syncing/FixContentSizes/FixContentSizes'
 import { ContentSizesFixRequestedEventHandler } from '../Domain/Handler/ContentSizesFixRequestedEventHandler'
+import { CheckForContentLimit } from '../Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit'
 
 export class ContainerConfigLoader {
+  private readonly DEFAULT_FREE_USER_CONTENT_LIMIT_BYTES = 100_000_000
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
   private readonly DEFAULT_MAX_ITEMS_LIMIT = 300
   private readonly DEFAULT_FILE_UPLOAD_PATH = `${__dirname}/../../uploads`
@@ -538,6 +540,13 @@ export class ContainerConfigLoader {
       .toConstantValue(
         env.get('MAX_ITEMS_LIMIT', true) ? +env.get('MAX_ITEMS_LIMIT', true) : this.DEFAULT_MAX_ITEMS_LIMIT,
       )
+    container
+      .bind<number>(TYPES.Sync_FREE_USER_CONTENT_LIMIT_BYTES)
+      .toConstantValue(
+        env.get('FREE_USER_CONTENT_LIMIT_BYTES', true)
+          ? +env.get('FREE_USER_CONTENT_LIMIT_BYTES', true)
+          : this.DEFAULT_FREE_USER_CONTENT_LIMIT_BYTES,
+      )
     container.bind(TYPES.Sync_VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true))
     container
       .bind(TYPES.Sync_VALET_TOKEN_TTL)
@@ -691,6 +700,14 @@ export class ContainerConfigLoader {
           container.get<MetricsStoreInterface>(TYPES.Sync_MetricsStore),
         ),
       )
+    container
+      .bind<CheckForContentLimit>(TYPES.Sync_CheckForContentLimit)
+      .toConstantValue(
+        new CheckForContentLimit(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
+          container.get<number>(TYPES.Sync_FREE_USER_CONTENT_LIMIT_BYTES),
+        ),
+      )
     container
       .bind<SaveItems>(TYPES.Sync_SaveItems)
       .toConstantValue(
@@ -703,6 +720,7 @@ export class ContainerConfigLoader {
           container.get<SendEventToClient>(TYPES.Sync_SendEventToClient),
           container.get<SendEventToClients>(TYPES.Sync_SendEventToClients),
           container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<CheckForContentLimit>(TYPES.Sync_CheckForContentLimit),
           container.get<Logger>(TYPES.Sync_Logger),
         ),
       )

+ 2 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -38,6 +38,7 @@ const TYPES = {
   Sync_VERSION: Symbol.for('Sync_VERSION'),
   Sync_CONTENT_SIZE_TRANSFER_LIMIT: Symbol.for('Sync_CONTENT_SIZE_TRANSFER_LIMIT'),
   Sync_MAX_ITEMS_LIMIT: Symbol.for('Sync_MAX_ITEMS_LIMIT'),
+  Sync_FREE_USER_CONTENT_LIMIT_BYTES: Symbol.for('Sync_FREE_USER_CONTENT_LIMIT_BYTES'),
   Sync_FILE_UPLOAD_PATH: Symbol.for('Sync_FILE_UPLOAD_PATH'),
   Sync_VALET_TOKEN_SECRET: Symbol.for('Sync_VALET_TOKEN_SECRET'),
   Sync_VALET_TOKEN_TTL: Symbol.for('Sync_VALET_TOKEN_TTL'),
@@ -84,6 +85,7 @@ const TYPES = {
   Sync_UpdateExistingItem: Symbol.for('Sync_UpdateExistingItem'),
   Sync_GetItems: Symbol.for('Sync_GetItems'),
   Sync_SaveItems: Symbol.for('Sync_SaveItems'),
+  Sync_CheckForContentLimit: Symbol.for('Sync_CheckForContentLimit'),
   Sync_GetUserNotifications: Symbol.for('Sync_GetUserNotifications'),
   Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'),
   Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'),

+ 4 - 0
packages/syncing-server/src/Domain/Item/ItemHash.ts

@@ -22,6 +22,10 @@ export class ItemHash extends ValueObject<ItemHashProps> {
     return this.props.shared_vault_uuid !== null
   }
 
+  calculateContentSize(): number {
+    return Buffer.byteLength(JSON.stringify(this))
+  }
+
   get sharedVaultUuid(): Uuid | null {
     if (!this.representsASharedVaultItem()) {
       return null

+ 95 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts

@@ -0,0 +1,95 @@
+import { ContentType } from '@standardnotes/domain-core'
+import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor'
+import { ItemHash } from '../../../Item/ItemHash'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { CheckForContentLimit } from './CheckForContentLimit'
+
+describe('CheckForContentLimit', () => {
+  let itemRepository: ItemRepositoryInterface
+  let freeUserContentLimitInBytes: number
+  let itemHash: ItemHash
+
+  const createUseCase = () => new CheckForContentLimit(itemRepository, freeUserContentLimitInBytes)
+
+  beforeEach(() => {
+    itemRepository = {} as ItemRepositoryInterface
+
+    itemHash = ItemHash.create({
+      uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'test content',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
+    }).getValue()
+
+    freeUserContentLimitInBytes = 100
+  })
+
+  it('should return a failure result if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({ userUuid: 'invalid-uuid', itemsBeingModified: [itemHash] })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should return a failure result if user has exceeded their content limit', async () => {
+    itemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 101).getValue()])
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      itemsBeingModified: [itemHash],
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should return a success result if user has not exceeded their content limit', async () => {
+    itemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 99).getValue()])
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      itemsBeingModified: [itemHash],
+    })
+
+    expect(result.isFailed()).toBe(false)
+  })
+
+  it('should return a success result if user has exceeded their content limit but user modifications are not increasing content size', async () => {
+    itemHash.calculateContentSize = jest.fn().mockReturnValue(99)
+
+    itemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 101).getValue()])
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      itemsBeingModified: [itemHash],
+    })
+
+    expect(result.isFailed()).toBe(false)
+  })
+
+  it('should treat items with no content size defined as 0', async () => {
+    itemHash.calculateContentSize = jest.fn().mockReturnValue(99)
+
+    itemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', null).getValue()])
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      itemsBeingModified: [itemHash],
+    })
+
+    expect(result.isFailed()).toBe(false)
+  })
+})

+ 66 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts

@@ -0,0 +1,66 @@
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import { CheckForContentLimitDTO } from './CheckForContentLimitDTO'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor'
+import { ItemHash } from '../../../Item/ItemHash'
+
+export class CheckForContentLimit implements UseCaseInterface<void> {
+  constructor(
+    private itemRepository: ItemRepositoryInterface,
+    private freeUserContentLimitInBytes: number,
+  ) {}
+
+  async execute(dto: CheckForContentLimitDTO): Promise<Result<void>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const contentSizeDescriptors = await this.itemRepository.findContentSizeForComputingTransferLimit({
+      userUuid: userUuid.value,
+    })
+
+    const isContentLimitExceeded = await this.isContentLimitExceeded(contentSizeDescriptors)
+    const isUserModificationsIncreasingContentSize = this.userModificationsAreIncreasingContentSize(
+      contentSizeDescriptors,
+      dto.itemsBeingModified,
+    )
+
+    if (isContentLimitExceeded && isUserModificationsIncreasingContentSize) {
+      return Result.fail('You have exceeded your content limit. Please upgrade your account.')
+    }
+
+    return Result.ok()
+  }
+
+  private userModificationsAreIncreasingContentSize(
+    contentSizeDescriptors: ItemContentSizeDescriptor[],
+    itemHashes: ItemHash[],
+  ): boolean {
+    for (const itemHash of itemHashes) {
+      const contentSizeDescriptor = contentSizeDescriptors.find(
+        (descriptor) => descriptor.props.uuid.value === itemHash.props.uuid,
+      )
+      if (contentSizeDescriptor) {
+        const afterModificationSize = itemHash.calculateContentSize()
+        const beforeModificationSize = contentSizeDescriptor.props.contentSize ?? 0
+        if (afterModificationSize > beforeModificationSize) {
+          return true
+        }
+      }
+    }
+
+    return false
+  }
+
+  private async isContentLimitExceeded(contentSizeDescriptors: ItemContentSizeDescriptor[]): Promise<boolean> {
+    const totalContentSize = contentSizeDescriptors.reduce(
+      (acc, descriptor) => acc + (descriptor.props.contentSize ? +descriptor.props.contentSize : 0),
+      0,
+    )
+
+    return totalContentSize > this.freeUserContentLimitInBytes
+  }
+}

+ 6 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts

@@ -0,0 +1,6 @@
+import { ItemHash } from '../../../Item/ItemHash'
+
+export interface CheckForContentLimitDTO {
+  userUuid: string
+  itemsBeingModified: ItemHash[]
+}

+ 41 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts

@@ -13,6 +13,7 @@ import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryIn
 import { ItemsChangedOnServerEvent } from '@standardnotes/domain-events'
 import { SendEventToClients } from '../SendEventToClients/SendEventToClients'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
+import { CheckForContentLimit } from '../CheckForContentLimit/CheckForContentLimit'
 
 describe('SaveItems', () => {
   let itemSaveValidator: ItemSaveValidatorInterface
@@ -26,6 +27,7 @@ describe('SaveItems', () => {
   let sendEventToClient: SendEventToClient
   let sendEventToClients: SendEventToClients
   let domainEventFactory: DomainEventFactoryInterface
+  let checkForContentLimit: CheckForContentLimit
 
   const createUseCase = () =>
     new SaveItems(
@@ -37,10 +39,14 @@ describe('SaveItems', () => {
       sendEventToClient,
       sendEventToClients,
       domainEventFactory,
+      checkForContentLimit,
       logger,
     )
 
   beforeEach(() => {
+    checkForContentLimit = {} as jest.Mocked<CheckForContentLimit>
+    checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.ok())
+
     sendEventToClient = {} as jest.Mocked<SendEventToClient>
     sendEventToClient.execute = jest.fn().mockReturnValue(Result.ok())
 
@@ -84,6 +90,7 @@ describe('SaveItems', () => {
     logger = {} as jest.Mocked<Logger>
     logger.debug = jest.fn()
     logger.error = jest.fn()
+    logger.warn = jest.fn()
 
     itemHash1 = ItemHash.create({
       uuid: '00000000-0000-0000-0000-000000000000',
@@ -397,4 +404,38 @@ describe('SaveItems', () => {
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2')
   })
+
+  it('should return a failure result if a free user has exceeded their content limit', async () => {
+    checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.fail('exceeded'))
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+      snjsVersion: '2.200.0',
+      isFreeUser: true,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should succeed if a free user has not exceeded their content limit', async () => {
+    checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.ok())
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+      snjsVersion: '2.200.0',
+      isFreeUser: true,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+  })
 })

+ 16 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts

@@ -14,6 +14,7 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { SendEventToClient } from '../SendEventToClient/SendEventToClient'
 import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
 import { SendEventToClients } from '../SendEventToClients/SendEventToClients'
+import { CheckForContentLimit } from '../CheckForContentLimit/CheckForContentLimit'
 
 export class SaveItems implements UseCaseInterface<SaveItemsResult> {
   private readonly SYNC_TOKEN_VERSION = 2
@@ -27,6 +28,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
     private sendEventToClient: SendEventToClient,
     private sendEventToClients: SendEventToClients,
     private domainEventFactory: DomainEventFactoryInterface,
+    private checkForContentLimit: CheckForContentLimit,
     private logger: Logger,
   ) {}
 
@@ -34,6 +36,20 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
     const savedItems: Array<Item> = []
     const conflicts: Array<ItemConflict> = []
 
+    if (dto.isFreeUser) {
+      const checkForContentLimitResult = await this.checkForContentLimit.execute({
+        userUuid: dto.userUuid,
+        itemsBeingModified: dto.itemHashes,
+      })
+      if (checkForContentLimitResult.isFailed()) {
+        this.logger.warn(`Checking for content limit failed. Error: ${checkForContentLimitResult.getError()}`, {
+          userId: dto.userUuid,
+        })
+
+        return Result.fail(checkForContentLimitResult.getError())
+      }
+    }
+
     const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
 
     for (const itemHash of dto.itemHashes) {