feat(syncing-server): add traffic abuse checks (#1014)
This commit is contained in:
parent
01641975c0
commit
b7173346d2
14 changed files with 377 additions and 47 deletions
|
@ -28,7 +28,7 @@ const sendStatistics = async (
|
|||
const dateNMinutesAgo = timer.getUTCDateNMinutesAgo(minutesToProcess - i)
|
||||
const timestamp = timer.convertDateToMicroseconds(dateNMinutesAgo)
|
||||
|
||||
const statistics = await metricsStore.getStatistics(
|
||||
const statistics = await metricsStore.getMetricsSummary(
|
||||
metricToProcess,
|
||||
timestamp,
|
||||
timestamp + Time.MicrosecondsInAMinute,
|
||||
|
@ -64,7 +64,7 @@ const sendStatistics = async (
|
|||
const dateNMinutesAgo = timer.getUTCDateNMinutesAgo(minutesToProcess - i)
|
||||
const timestamp = timer.convertDateToMicroseconds(dateNMinutesAgo)
|
||||
|
||||
const statistics = await metricsStore.getUserBasedStatistics(metricToProcess, timestamp)
|
||||
const statistics = await metricsStore.getUserBasedMetricsSummary(metricToProcess, timestamp)
|
||||
|
||||
if (statistics.sampleCount === 0) {
|
||||
continue
|
||||
|
|
|
@ -166,6 +166,7 @@ import { SendEventToClients } from '../Domain/UseCase/Syncing/SendEventToClients
|
|||
import { MetricsStoreInterface } from '../Domain/Metrics/MetricsStoreInterface'
|
||||
import { RedisMetricStore } from '../Infra/Redis/RedisMetricStore'
|
||||
import { DummyMetricStore } from '../Infra/Dummy/DummyMetricStore'
|
||||
import { CheckForTrafficAbuse } from '../Domain/UseCase/Syncing/CheckForTrafficAbuse/CheckForTrafficAbuse'
|
||||
|
||||
export class ContainerConfigLoader {
|
||||
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
|
||||
|
@ -472,6 +473,33 @@ export class ContainerConfigLoader {
|
|||
})
|
||||
|
||||
// env vars
|
||||
container
|
||||
.bind(TYPES.Sync_STRICT_ABUSE_PROTECTION)
|
||||
.toConstantValue(env.get('STRICT_ABUSE_PROTECTION', true) === 'true')
|
||||
container
|
||||
.bind(TYPES.Sync_ITEM_OPERATIONS_ABUSE_THRESHOLD)
|
||||
.toConstantValue(
|
||||
env.get('ITEM_OPERATIONS_ABUSE_THRESHOLD', true) ? +env.get('ITEM_OPERATIONS_ABUSE_THRESHOLD', true) : 500,
|
||||
)
|
||||
container
|
||||
.bind(TYPES.Sync_ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES)
|
||||
.toConstantValue(
|
||||
env.get('ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES', true)
|
||||
? +env.get('ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES', true)
|
||||
: 5,
|
||||
)
|
||||
container
|
||||
.bind(TYPES.Sync_PAYLOAD_SIZE_ABUSE_THRESHOLD)
|
||||
.toConstantValue(
|
||||
env.get('PAYLOAD_SIZE_ABUSE_THRESHOLD', true) ? +env.get('PAYLOAD_SIZE_ABUSE_THRESHOLD', true) : 20_000_000,
|
||||
)
|
||||
container
|
||||
.bind(TYPES.Sync_PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES)
|
||||
.toConstantValue(
|
||||
env.get('PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES', true)
|
||||
? +env.get('PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES', true)
|
||||
: 5,
|
||||
)
|
||||
container.bind(TYPES.Sync_AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET'))
|
||||
container
|
||||
.bind(TYPES.Sync_REVISIONS_FREQUENCY)
|
||||
|
@ -933,6 +961,14 @@ export class ContainerConfigLoader {
|
|||
context.container.get(TYPES.Sync_SyncResponseFactory20200115),
|
||||
)
|
||||
})
|
||||
container
|
||||
.bind<CheckForTrafficAbuse>(TYPES.Sync_CheckForTrafficAbuse)
|
||||
.toConstantValue(
|
||||
new CheckForTrafficAbuse(
|
||||
container.get<MetricsStoreInterface>(TYPES.Sync_MetricsStore),
|
||||
container.get<TimerInterface>(TYPES.Sync_Timer),
|
||||
),
|
||||
)
|
||||
|
||||
// Handlers
|
||||
container
|
||||
|
@ -1094,11 +1130,18 @@ export class ContainerConfigLoader {
|
|||
.bind<BaseItemsController>(TYPES.Sync_BaseItemsController)
|
||||
.toConstantValue(
|
||||
new BaseItemsController(
|
||||
container.get<CheckForTrafficAbuse>(TYPES.Sync_CheckForTrafficAbuse),
|
||||
container.get<SyncItems>(TYPES.Sync_SyncItems),
|
||||
container.get<CheckIntegrity>(TYPES.Sync_CheckIntegrity),
|
||||
container.get<GetItem>(TYPES.Sync_GetItem),
|
||||
container.get<MapperInterface<Item, ItemHttpRepresentation>>(TYPES.Sync_ItemHttpMapper),
|
||||
container.get<SyncResponseFactoryResolverInterface>(TYPES.Sync_SyncResponseFactoryResolver),
|
||||
container.get<Logger>(TYPES.Sync_Logger),
|
||||
container.get<boolean>(TYPES.Sync_STRICT_ABUSE_PROTECTION),
|
||||
container.get<number>(TYPES.Sync_ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES),
|
||||
container.get<number>(TYPES.Sync_ITEM_OPERATIONS_ABUSE_THRESHOLD),
|
||||
container.get<number>(TYPES.Sync_PAYLOAD_SIZE_ABUSE_THRESHOLD),
|
||||
container.get<number>(TYPES.Sync_PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES),
|
||||
container.get<ControllerContainerInterface>(TYPES.Sync_ControllerContainer),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -42,6 +42,15 @@ const TYPES = {
|
|||
Sync_VALET_TOKEN_SECRET: Symbol.for('Sync_VALET_TOKEN_SECRET'),
|
||||
Sync_VALET_TOKEN_TTL: Symbol.for('Sync_VALET_TOKEN_TTL'),
|
||||
Sync_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for('Sync_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING'),
|
||||
Sync_STRICT_ABUSE_PROTECTION: Symbol.for('Sync_STRICT_ABUSE_PROTECTION'),
|
||||
Sync_ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES: Symbol.for(
|
||||
'Sync_ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES',
|
||||
),
|
||||
Sync_ITEM_OPERATIONS_ABUSE_THRESHOLD: Symbol.for('Sync_ITEM_OPERATIONS_ABUSE_THRESHOLD'),
|
||||
Sync_PAYLOAD_SIZE_ABUSE_THRESHOLD: Symbol.for('Sync_PAYLOAD_SIZE_ABUSE_THRESHOLD'),
|
||||
Sync_PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES: Symbol.for(
|
||||
'Sync_PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES',
|
||||
),
|
||||
// use cases
|
||||
Sync_SyncItems: Symbol.for('Sync_SyncItems'),
|
||||
Sync_CheckIntegrity: Symbol.for('Sync_CheckIntegrity'),
|
||||
|
@ -85,6 +94,7 @@ const TYPES = {
|
|||
Sync_TransferSharedVault: Symbol.for('Sync_TransferSharedVault'),
|
||||
Sync_TransferSharedVaultItems: Symbol.for('Sync_TransferSharedVaultItems'),
|
||||
Sync_DumpItem: Symbol.for('Sync_DumpItem'),
|
||||
Sync_CheckForTrafficAbuse: Symbol.for('Sync_CheckForTrafficAbuse'),
|
||||
// Handlers
|
||||
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
|
||||
Sync_AccountDeletionVerificationRequestedEventHandler: Symbol.for(
|
||||
|
|
|
@ -1,25 +1,17 @@
|
|||
import { Uuid } from '@standardnotes/domain-core'
|
||||
|
||||
import { Metric } from './Metric'
|
||||
import { MetricsSummary } from './MetricsSummary'
|
||||
|
||||
export interface MetricsStoreInterface {
|
||||
storeUserBasedMetric(metric: Metric, value: number, userUuid: string): Promise<void>
|
||||
getUserBasedStatistics(
|
||||
name: string,
|
||||
timestamp: number,
|
||||
): Promise<{
|
||||
sum: number
|
||||
max: number
|
||||
min: number
|
||||
sampleCount: number
|
||||
}>
|
||||
storeMetric(metric: Metric): Promise<void>
|
||||
getStatistics(
|
||||
name: string,
|
||||
from: number,
|
||||
to: number,
|
||||
): Promise<{
|
||||
sum: number
|
||||
max: number
|
||||
min: number
|
||||
sampleCount: number
|
||||
}>
|
||||
storeUserBasedMetric(metric: Metric, value: number, userUuid: Uuid): Promise<void>
|
||||
getUserBasedMetricsSummaryWithinTimeRange(dto: {
|
||||
metricName: string
|
||||
userUuid: Uuid
|
||||
from: Date
|
||||
to: Date
|
||||
}): Promise<MetricsSummary>
|
||||
getUserBasedMetricsSummary(name: string, timestamp: number): Promise<MetricsSummary>
|
||||
getMetricsSummary(name: string, from: number, to: number): Promise<MetricsSummary>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export interface MetricsSummary {
|
||||
sum: number
|
||||
max: number
|
||||
min: number
|
||||
sampleCount: number
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { MetricsStoreInterface } from '../../../Metrics/MetricsStoreInterface'
|
||||
import { CheckForTrafficAbuse } from './CheckForTrafficAbuse'
|
||||
import { MetricsSummary } from '../../../Metrics/MetricsSummary'
|
||||
import { Metric } from '../../../Metrics/Metric'
|
||||
|
||||
describe('CheckForTrafficAbuse', () => {
|
||||
let metricsStore: MetricsStoreInterface
|
||||
let timer: TimerInterface
|
||||
let timeframeLengthInMinutes: number
|
||||
let threshold: number
|
||||
|
||||
const createUseCase = () => new CheckForTrafficAbuse(metricsStore, timer)
|
||||
|
||||
beforeEach(() => {
|
||||
const metricsSummary: MetricsSummary = {
|
||||
sum: 101,
|
||||
max: 0,
|
||||
min: 0,
|
||||
sampleCount: 0,
|
||||
}
|
||||
|
||||
metricsStore = {} as jest.Mocked<MetricsStoreInterface>
|
||||
metricsStore.getUserBasedMetricsSummaryWithinTimeRange = jest.fn().mockReturnValue(metricsSummary)
|
||||
|
||||
timer = {} as TimerInterface
|
||||
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(0)
|
||||
timer.getUTCDateNMinutesAgo = jest.fn().mockReturnValue(new Date(0))
|
||||
timer.getUTCDate = jest.fn().mockReturnValue(new Date(10))
|
||||
|
||||
timeframeLengthInMinutes = 5
|
||||
|
||||
threshold = 100
|
||||
})
|
||||
|
||||
it('returns a failure result if the user uuid is invalid', async () => {
|
||||
const result = await createUseCase().execute({
|
||||
userUuid: 'invalid',
|
||||
metricToCheck: Metric.NAMES.ItemOperation,
|
||||
timeframeLengthInMinutes,
|
||||
threshold,
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('return a failure result if the metric name is invalid', async () => {
|
||||
const result = await createUseCase().execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
metricToCheck: 'invalid',
|
||||
timeframeLengthInMinutes,
|
||||
threshold,
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns a failure result if the metric summary is above the threshold', async () => {
|
||||
const metricsSummary: MetricsSummary = {
|
||||
sum: 101,
|
||||
max: 0,
|
||||
min: 0,
|
||||
sampleCount: 0,
|
||||
}
|
||||
|
||||
metricsStore.getUserBasedMetricsSummaryWithinTimeRange = jest.fn().mockReturnValue(metricsSummary)
|
||||
|
||||
const result = await createUseCase().execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
metricToCheck: Metric.NAMES.ItemOperation,
|
||||
timeframeLengthInMinutes,
|
||||
threshold,
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns a success result if the metric summary is below the threshold', async () => {
|
||||
const metricsSummary: MetricsSummary = {
|
||||
sum: 99,
|
||||
max: 0,
|
||||
min: 0,
|
||||
sampleCount: 0,
|
||||
}
|
||||
|
||||
metricsStore.getUserBasedMetricsSummaryWithinTimeRange = jest.fn().mockReturnValue(metricsSummary)
|
||||
|
||||
const result = await createUseCase().execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
metricToCheck: Metric.NAMES.ItemOperation,
|
||||
timeframeLengthInMinutes,
|
||||
threshold,
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,44 @@
|
|||
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
|
||||
import { CheckForTrafficAbuseDTO } from './CheckForTrafficAbuseDTO'
|
||||
import { MetricsStoreInterface } from '../../../Metrics/MetricsStoreInterface'
|
||||
import { Metric } from '../../../Metrics/Metric'
|
||||
import { MetricsSummary } from '../../../Metrics/MetricsSummary'
|
||||
|
||||
export class CheckForTrafficAbuse implements UseCaseInterface<MetricsSummary> {
|
||||
constructor(
|
||||
private metricsStore: MetricsStoreInterface,
|
||||
private timer: TimerInterface,
|
||||
) {}
|
||||
|
||||
async execute(dto: CheckForTrafficAbuseDTO): Promise<Result<MetricsSummary>> {
|
||||
const userUuidOrError = Uuid.create(dto.userUuid)
|
||||
if (userUuidOrError.isFailed()) {
|
||||
return Result.fail(userUuidOrError.getError())
|
||||
}
|
||||
const userUuid = userUuidOrError.getValue()
|
||||
|
||||
const metricToCheckOrError = Metric.create({
|
||||
name: dto.metricToCheck,
|
||||
timestamp: this.timer.getTimestampInMicroseconds(),
|
||||
})
|
||||
if (metricToCheckOrError.isFailed()) {
|
||||
return Result.fail(metricToCheckOrError.getError())
|
||||
}
|
||||
const metricToCheck = metricToCheckOrError.getValue()
|
||||
|
||||
const metricsSummary = await this.metricsStore.getUserBasedMetricsSummaryWithinTimeRange({
|
||||
metricName: metricToCheck.props.name,
|
||||
userUuid,
|
||||
from: this.timer.getUTCDateNMinutesAgo(dto.timeframeLengthInMinutes),
|
||||
to: this.timer.getUTCDate(),
|
||||
})
|
||||
|
||||
if (metricsSummary.sum > dto.threshold) {
|
||||
return Result.fail(`Traffic abuse detected for metric: ${metricToCheck.props.name}`)
|
||||
}
|
||||
|
||||
return Result.ok(metricsSummary)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface CheckForTrafficAbuseDTO {
|
||||
userUuid: string
|
||||
metricToCheck: string
|
||||
timeframeLengthInMinutes: number
|
||||
threshold: number
|
||||
}
|
|
@ -145,7 +145,7 @@ export class SaveNewItem implements UseCaseInterface<Item> {
|
|||
timestamp: this.timer.getTimestampInMicroseconds(),
|
||||
}).getValue(),
|
||||
newItem.props.contentSize,
|
||||
userUuid.value,
|
||||
userUuid,
|
||||
)
|
||||
|
||||
await this.metricsStore.storeMetric(
|
||||
|
|
|
@ -186,7 +186,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
|
|||
timestamp: this.timer.getTimestampInMicroseconds(),
|
||||
}).getValue(),
|
||||
dto.existingItem.props.contentSize,
|
||||
userUuid.value,
|
||||
userUuid,
|
||||
)
|
||||
|
||||
/* istanbul ignore next */
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { Uuid } from '@standardnotes/domain-core'
|
||||
|
||||
import { MetricsStoreInterface } from '../../Domain/Metrics/MetricsStoreInterface'
|
||||
import { Metric } from '../../Domain/Metrics/Metric'
|
||||
import { MetricsSummary } from '../../Domain/Metrics/MetricsSummary'
|
||||
|
||||
export class DummyMetricStore implements MetricsStoreInterface {
|
||||
async getUserBasedStatistics(
|
||||
_name: string,
|
||||
_timestamp: number,
|
||||
): Promise<{ sum: number; max: number; min: number; sampleCount: number }> {
|
||||
async getUserBasedMetricsSummaryWithinTimeRange(_dto: {
|
||||
metricName: string
|
||||
userUuid: Uuid
|
||||
from: Date
|
||||
to: Date
|
||||
}): Promise<MetricsSummary> {
|
||||
return {
|
||||
sum: 0,
|
||||
max: 0,
|
||||
|
@ -14,7 +19,16 @@ export class DummyMetricStore implements MetricsStoreInterface {
|
|||
}
|
||||
}
|
||||
|
||||
async storeUserBasedMetric(_metric: Metric, _value: number, _userUuid: string): Promise<void> {
|
||||
async getUserBasedMetricsSummary(_name: string, _timestamp: number): Promise<MetricsSummary> {
|
||||
return {
|
||||
sum: 0,
|
||||
max: 0,
|
||||
min: 0,
|
||||
sampleCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async storeUserBasedMetric(_metric: Metric, _value: number, _userUuid: Uuid): Promise<void> {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
|
@ -22,11 +36,7 @@ export class DummyMetricStore implements MetricsStoreInterface {
|
|||
// do nothing
|
||||
}
|
||||
|
||||
async getStatistics(
|
||||
_name: string,
|
||||
_from: number,
|
||||
_to: number,
|
||||
): Promise<{ sum: number; max: number; min: number; sampleCount: number }> {
|
||||
async getMetricsSummary(_name: string, _from: number, _to: number): Promise<MetricsSummary> {
|
||||
return {
|
||||
sum: 0,
|
||||
max: 0,
|
||||
|
|
|
@ -11,18 +11,42 @@ import { SyncItems } from '../../Domain/UseCase/Syncing/SyncItems/SyncItems'
|
|||
import { BaseItemsController } from './Base/BaseItemsController'
|
||||
import { MapperInterface } from '@standardnotes/domain-core'
|
||||
import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
|
||||
import { CheckForTrafficAbuse } from '../../Domain/UseCase/Syncing/CheckForTrafficAbuse/CheckForTrafficAbuse'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
@controller('/items', TYPES.Sync_AuthMiddleware)
|
||||
export class AnnotatedItemsController extends BaseItemsController {
|
||||
constructor(
|
||||
@inject(TYPES.Sync_CheckForTrafficAbuse) override checkForTrafficAbuse: CheckForTrafficAbuse,
|
||||
@inject(TYPES.Sync_SyncItems) override syncItems: SyncItems,
|
||||
@inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity,
|
||||
@inject(TYPES.Sync_GetItem) override getItem: GetItem,
|
||||
@inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
|
||||
@inject(TYPES.Sync_SyncResponseFactoryResolver)
|
||||
override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
|
||||
@inject(TYPES.Sync_Logger) override logger: Logger,
|
||||
@inject(TYPES.Sync_STRICT_ABUSE_PROTECTION) override strictAbuseProtection: boolean,
|
||||
@inject(TYPES.Sync_ITEM_OPERATIONS_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES)
|
||||
override itemOperationsAbuseTimeframeLengthInMinutes: number,
|
||||
@inject(TYPES.Sync_ITEM_OPERATIONS_ABUSE_THRESHOLD) override itemOperationsAbuseThreshold: number,
|
||||
@inject(TYPES.Sync_PAYLOAD_SIZE_ABUSE_THRESHOLD) override payloadSizeAbuseThreshold: number,
|
||||
@inject(TYPES.Sync_PAYLOAD_SIZE_ABUSE_TIMEFRAME_LENGTH_IN_MINUTES)
|
||||
override payloadSizeAbuseTimeframeLengthInMinutes: number,
|
||||
) {
|
||||
super(syncItems, checkIntegrity, getItem, itemHttpMapper, syncResponseFactoryResolver)
|
||||
super(
|
||||
checkForTrafficAbuse,
|
||||
syncItems,
|
||||
checkIntegrity,
|
||||
getItem,
|
||||
itemHttpMapper,
|
||||
syncResponseFactoryResolver,
|
||||
logger,
|
||||
strictAbuseProtection,
|
||||
itemOperationsAbuseTimeframeLengthInMinutes,
|
||||
itemOperationsAbuseThreshold,
|
||||
payloadSizeAbuseThreshold,
|
||||
payloadSizeAbuseTimeframeLengthInMinutes,
|
||||
)
|
||||
}
|
||||
|
||||
@httpPost('/sync')
|
||||
|
|
|
@ -11,14 +11,24 @@ import { ApiVersion } from '../../../Domain/Api/ApiVersion'
|
|||
import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
|
||||
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
|
||||
import { ItemHash } from '../../../Domain/Item/ItemHash'
|
||||
import { CheckForTrafficAbuse } from '../../../Domain/UseCase/Syncing/CheckForTrafficAbuse/CheckForTrafficAbuse'
|
||||
import { Metric } from '../../../Domain/Metrics/Metric'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
export class BaseItemsController extends BaseHttpController {
|
||||
constructor(
|
||||
protected checkForTrafficAbuse: CheckForTrafficAbuse,
|
||||
protected syncItems: SyncItems,
|
||||
protected checkIntegrity: CheckIntegrity,
|
||||
protected getItem: GetItem,
|
||||
protected itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
|
||||
protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
|
||||
protected logger: Logger,
|
||||
protected strictAbuseProtection: boolean,
|
||||
protected itemOperationsAbuseTimeframeLengthInMinutes: number,
|
||||
protected itemOperationsAbuseThreshold: number,
|
||||
protected payloadSizeAbuseThreshold: number,
|
||||
protected payloadSizeAbuseTimeframeLengthInMinutes: number,
|
||||
private controllerContainer?: ControllerContainerInterface,
|
||||
) {
|
||||
super()
|
||||
|
@ -31,6 +41,37 @@ export class BaseItemsController extends BaseHttpController {
|
|||
}
|
||||
|
||||
async sync(request: Request, response: Response): Promise<results.JsonResult> {
|
||||
const checkForItemOperationsAbuseResult = await this.checkForTrafficAbuse.execute({
|
||||
metricToCheck: Metric.NAMES.ItemOperation,
|
||||
userUuid: response.locals.user.uuid,
|
||||
threshold: this.itemOperationsAbuseThreshold,
|
||||
timeframeLengthInMinutes: this.itemOperationsAbuseTimeframeLengthInMinutes,
|
||||
})
|
||||
if (checkForItemOperationsAbuseResult.isFailed()) {
|
||||
this.logger.warn(checkForItemOperationsAbuseResult.getError(), {
|
||||
userId: response.locals.user.uuid,
|
||||
})
|
||||
if (this.strictAbuseProtection) {
|
||||
return this.json({ error: { message: checkForItemOperationsAbuseResult.getError() } }, 429)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForPayloadSizeAbuseResult = await this.checkForTrafficAbuse.execute({
|
||||
metricToCheck: Metric.NAMES.ContentSizeUtilized,
|
||||
userUuid: response.locals.user.uuid,
|
||||
threshold: this.payloadSizeAbuseThreshold,
|
||||
timeframeLengthInMinutes: this.payloadSizeAbuseTimeframeLengthInMinutes,
|
||||
})
|
||||
if (checkForPayloadSizeAbuseResult.isFailed()) {
|
||||
this.logger.warn(checkForPayloadSizeAbuseResult.getError(), {
|
||||
userId: response.locals.user.uuid,
|
||||
})
|
||||
|
||||
if (this.strictAbuseProtection) {
|
||||
return this.json({ error: { message: checkForPayloadSizeAbuseResult.getError() } }, 429)
|
||||
}
|
||||
}
|
||||
|
||||
const itemHashes: ItemHash[] = []
|
||||
if ('items' in request.body) {
|
||||
for (const itemHashInput of request.body.items) {
|
||||
|
|
|
@ -3,6 +3,8 @@ import { TimerInterface } from '@standardnotes/time'
|
|||
|
||||
import { MetricsStoreInterface } from '../../Domain/Metrics/MetricsStoreInterface'
|
||||
import { Metric } from '../../Domain/Metrics/Metric'
|
||||
import { Uuid } from '@standardnotes/domain-core'
|
||||
import { MetricsSummary } from '../../Domain/Metrics/MetricsSummary'
|
||||
|
||||
export class RedisMetricStore implements MetricsStoreInterface {
|
||||
private readonly METRIC_PREFIX = 'metric'
|
||||
|
@ -13,15 +15,61 @@ export class RedisMetricStore implements MetricsStoreInterface {
|
|||
private timer: TimerInterface,
|
||||
) {}
|
||||
|
||||
async storeUserBasedMetric(metric: Metric, value: number, userUuid: string): Promise<void> {
|
||||
async getUserBasedMetricsSummaryWithinTimeRange(dto: {
|
||||
metricName: string
|
||||
userUuid: Uuid
|
||||
from: Date
|
||||
to: Date
|
||||
}): Promise<MetricsSummary> {
|
||||
const keys = this.getKeysRepresentingMinutesBetweenFromAndTo(dto.from, dto.to)
|
||||
|
||||
let sum = 0
|
||||
let max = 0
|
||||
let min = 0
|
||||
let sampleCount = 0
|
||||
|
||||
const values = await this.redisClient.mget(
|
||||
keys.map((key) => `${this.METRIC_PER_USER_PREFIX}:${dto.userUuid.value}:${dto.metricName}:${key}`),
|
||||
)
|
||||
|
||||
for (const value of values) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const valueAsNumber = Number(value)
|
||||
|
||||
sum += valueAsNumber
|
||||
sampleCount++
|
||||
|
||||
if (valueAsNumber > max) {
|
||||
max = valueAsNumber
|
||||
}
|
||||
|
||||
if (valueAsNumber < min) {
|
||||
min = valueAsNumber
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sum,
|
||||
max,
|
||||
min,
|
||||
sampleCount,
|
||||
}
|
||||
}
|
||||
|
||||
async storeUserBasedMetric(metric: Metric, value: number, userUuid: Uuid): Promise<void> {
|
||||
const date = this.timer.convertMicrosecondsToDate(metric.props.timestamp)
|
||||
const dateToTheMinuteString = this.timer.convertDateToFormattedString(date, 'YYYY-MM-DD HH:mm')
|
||||
const key = `${this.METRIC_PER_USER_PREFIX}:${userUuid}:${metric.props.name}:${dateToTheMinuteString}`
|
||||
const key = `${this.METRIC_PER_USER_PREFIX}:${userUuid.value}:${metric.props.name}:${dateToTheMinuteString}`
|
||||
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
pipeline.incrbyfloat(key, value)
|
||||
pipeline.incr(`${this.METRIC_PER_USER_PREFIX}:${userUuid}:${Metric.NAMES.ItemOperation}:${dateToTheMinuteString}`)
|
||||
pipeline.incr(
|
||||
`${this.METRIC_PER_USER_PREFIX}:${userUuid.value}:${Metric.NAMES.ItemOperation}:${dateToTheMinuteString}`,
|
||||
)
|
||||
|
||||
const expirationTime = 60 * 60 * 24
|
||||
pipeline.expire(key, expirationTime)
|
||||
|
@ -29,10 +77,7 @@ export class RedisMetricStore implements MetricsStoreInterface {
|
|||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async getUserBasedStatistics(
|
||||
name: string,
|
||||
timestamp: number,
|
||||
): Promise<{ sum: number; max: number; min: number; sampleCount: number }> {
|
||||
async getUserBasedMetricsSummary(name: string, timestamp: number): Promise<MetricsSummary> {
|
||||
const date = this.timer.convertMicrosecondsToDate(timestamp)
|
||||
const dateToTheMinuteString = this.timer.convertDateToFormattedString(date, 'YYYY-MM-DD HH:mm')
|
||||
|
||||
|
@ -74,11 +119,7 @@ export class RedisMetricStore implements MetricsStoreInterface {
|
|||
}
|
||||
}
|
||||
|
||||
async getStatistics(
|
||||
name: string,
|
||||
from: number,
|
||||
to: number,
|
||||
): Promise<{ sum: number; max: number; min: number; sampleCount: number }> {
|
||||
async getMetricsSummary(name: string, from: number, to: number): Promise<MetricsSummary> {
|
||||
const keysRepresentingSecondsBetweenFromAndTo = this.getKeysRepresentingSecondsBetweenFromAndTo(from, to)
|
||||
|
||||
let sum = 0
|
||||
|
@ -132,6 +173,22 @@ export class RedisMetricStore implements MetricsStoreInterface {
|
|||
await pipeline.exec()
|
||||
}
|
||||
|
||||
private getKeysRepresentingMinutesBetweenFromAndTo(from: Date, to: Date): string[] {
|
||||
const keys: string[] = []
|
||||
|
||||
let currentMinute = from
|
||||
|
||||
while (currentMinute <= to) {
|
||||
const dateToTheMinuteString = this.timer.convertDateToFormattedString(currentMinute, 'YYYY-MM-DD HH:mm')
|
||||
|
||||
keys.push(dateToTheMinuteString)
|
||||
|
||||
currentMinute = new Date(currentMinute.getTime() + 60 * 1000)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
private getKeysRepresentingSecondsBetweenFromAndTo(from: number, to: number): string[] {
|
||||
const keys: string[] = []
|
||||
|
||||
|
|
Loading…
Reference in a new issue