Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/syncing-server/src/Bootstrap/Container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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),
),
)
Expand Down
2 changes: 2 additions & 0 deletions packages/syncing-server/src/Bootstrap/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
4 changes: 4 additions & 0 deletions packages/syncing-server/src/Domain/Item/ItemHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ItemHash } from '../../../Item/ItemHash'

export interface CheckForContentLimitDTO {
userUuid: string
itemsBeingModified: ItemHash[]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,7 @@ describe('SaveItems', () => {
let sendEventToClient: SendEventToClient
let sendEventToClients: SendEventToClients
let domainEventFactory: DomainEventFactoryInterface
let checkForContentLimit: CheckForContentLimit

const createUseCase = () =>
new SaveItems(
Expand All @@ -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())

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,13 +28,28 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
private sendEventToClient: SendEventToClient,
private sendEventToClients: SendEventToClients,
private domainEventFactory: DomainEventFactoryInterface,
private checkForContentLimit: CheckForContentLimit,
private logger: Logger,
) {}

async execute(dto: SaveItemsDTO): Promise<Result<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) {
Expand Down