From 484d5a938e0b065f96672ec16908cab5fcd35291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Tue, 6 Feb 2024 11:54:55 +0100 Subject: [PATCH] feat(syncing-server): add checking for content limit on free accounts --- .../syncing-server/src/Bootstrap/Container.ts | 18 ++++ .../syncing-server/src/Bootstrap/Types.ts | 2 + .../src/Domain/Item/ItemHash.ts | 4 + .../CheckForContentLimit.spec.ts | 95 +++++++++++++++++++ .../CheckForContentLimit.ts | 66 +++++++++++++ .../CheckForContentLimitDTO.ts | 6 ++ .../Syncing/SaveItems/SaveItems.spec.ts | 41 ++++++++ .../UseCase/Syncing/SaveItems/SaveItems.ts | 16 ++++ 8 files changed, 248 insertions(+) create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index dc93e4a12..9d20a8ce4 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/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(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(TYPES.Sync_MetricsStore), ), ) + container + .bind(TYPES.Sync_CheckForContentLimit) + .toConstantValue( + new CheckForContentLimit( + container.get(TYPES.Sync_SQLItemRepository), + container.get(TYPES.Sync_FREE_USER_CONTENT_LIMIT_BYTES), + ), + ) container .bind(TYPES.Sync_SaveItems) .toConstantValue( @@ -703,6 +720,7 @@ export class ContainerConfigLoader { container.get(TYPES.Sync_SendEventToClient), container.get(TYPES.Sync_SendEventToClients), container.get(TYPES.Sync_DomainEventFactory), + container.get(TYPES.Sync_CheckForContentLimit), container.get(TYPES.Sync_Logger), ), ) diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index 7d71b9a33..1c2abbebb 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/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'), diff --git a/packages/syncing-server/src/Domain/Item/ItemHash.ts b/packages/syncing-server/src/Domain/Item/ItemHash.ts index 447e8a8b6..95351be32 100644 --- a/packages/syncing-server/src/Domain/Item/ItemHash.ts +++ b/packages/syncing-server/src/Domain/Item/ItemHash.ts @@ -22,6 +22,10 @@ export class ItemHash extends ValueObject { return this.props.shared_vault_uuid !== null } + calculateContentSize(): number { + return Buffer.byteLength(JSON.stringify(this)) + } + get sharedVaultUuid(): Uuid | null { if (!this.representsASharedVaultItem()) { return null diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts new file mode 100644 index 000000000..476b2e89a --- /dev/null +++ b/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) + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts new file mode 100644 index 000000000..3bc49778d --- /dev/null +++ b/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 { + constructor( + private itemRepository: ItemRepositoryInterface, + private freeUserContentLimitInBytes: number, + ) {} + + async execute(dto: CheckForContentLimitDTO): Promise> { + 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 { + const totalContentSize = contentSizeDescriptors.reduce( + (acc, descriptor) => acc + (descriptor.props.contentSize ? +descriptor.props.contentSize : 0), + 0, + ) + + return totalContentSize > this.freeUserContentLimitInBytes + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts new file mode 100644 index 000000000..41220e9c9 --- /dev/null +++ b/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[] +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts index 5a74dc780..d03d03c51 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts +++ b/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.execute = jest.fn().mockResolvedValue(Result.ok()) + sendEventToClient = {} as jest.Mocked sendEventToClient.execute = jest.fn().mockReturnValue(Result.ok()) @@ -84,6 +90,7 @@ describe('SaveItems', () => { logger = {} as jest.Mocked 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() + }) }) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts index ec81d1059..75f791369 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts +++ b/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 { private readonly SYNC_TOKEN_VERSION = 2 @@ -27,6 +28,7 @@ export class SaveItems implements UseCaseInterface { private sendEventToClient: SendEventToClient, private sendEventToClients: SendEventToClients, private domainEventFactory: DomainEventFactoryInterface, + private checkForContentLimit: CheckForContentLimit, private logger: Logger, ) {} @@ -34,6 +36,20 @@ export class SaveItems implements UseCaseInterface { const savedItems: Array = [] const conflicts: Array = [] + 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) {