feat(syncing-server): add checking for content limit on free accounts
This commit is contained in:
parent
f975dd9697
commit
484d5a938e
8 changed files with 248 additions and 0 deletions
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { ItemHash } from '../../../Item/ItemHash'
|
||||
|
||||
export interface CheckForContentLimitDTO {
|
||||
userUuid: string
|
||||
itemsBeingModified: ItemHash[]
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue