feat(syncing-server): add checking for content limit on free accounts

This commit is contained in:
Karol Sójko 2024-02-06 11:54:55 +01:00
parent f975dd9697
commit 484d5a938e
No known key found for this signature in database
GPG key ID: C2F813669419D05F
8 changed files with 248 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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