From 7beda7187f45956368abf78f822def1608832fe3 Mon Sep 17 00:00:00 2001 From: 49659410+tx0c <> Date: Tue, 16 Apr 2024 07:23:23 +0000 Subject: [PATCH] feat(publish-quota): per user publishArticle quota resolves #3842 --- ...40417164251_alter_user_add_publish_rate.js | 13 ++++ src/common/enums/user.ts | 5 ++ src/common/utils/index.ts | 1 + src/common/utils/rateLimit.ts | 64 ++++++++++++++++++ src/mutations/article/publishArticle.ts | 26 +++++++- src/types/directives/rateLimit.ts | 65 +------------------ 6 files changed, 108 insertions(+), 66 deletions(-) create mode 100644 db/migrations/20240417164251_alter_user_add_publish_rate.js create mode 100644 src/common/utils/rateLimit.ts diff --git a/db/migrations/20240417164251_alter_user_add_publish_rate.js b/db/migrations/20240417164251_alter_user_add_publish_rate.js new file mode 100644 index 000000000..39d884b1e --- /dev/null +++ b/db/migrations/20240417164251_alter_user_add_publish_rate.js @@ -0,0 +1,13 @@ +const table = 'user' + +exports.up = async (knex) => { + await knex.schema.table(table, (t) => { + t.jsonb('publish_rate') + }) +} + +exports.down = async (knex) => { + await knex.schema.table(table, (t) => { + t.dropColumn('publish_rate') + }) +} diff --git a/src/common/enums/user.ts b/src/common/enums/user.ts index b7d1a33ca..04e7e6ca9 100644 --- a/src/common/enums/user.ts +++ b/src/common/enums/user.ts @@ -1,3 +1,5 @@ +import { isProd } from 'common/environment' + export const USER_STATE = { frozen: 'frozen', active: 'active', @@ -16,3 +18,6 @@ export const AUTHOR_TYPE = { default: 'default', trendy: 'trendy', } as const + +export const PUBLISH_ARTICLE_RATE_LIMIT = isProd ? 1 : 1000 +export const PUBLISH_ARTICLE_RATE_PERIOD = 720 // for 12 minutes; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index ab340ad63..f8f560790 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -25,6 +25,7 @@ export * from './genDisplayName' export * from './counter' export * from './verify' export * from './nanoid' +export * from './rateLimit' /** * Make a valid user name based on a given email address. It removes all special characters including _. diff --git a/src/common/utils/rateLimit.ts b/src/common/utils/rateLimit.ts new file mode 100644 index 000000000..ab2e6a6a3 --- /dev/null +++ b/src/common/utils/rateLimit.ts @@ -0,0 +1,64 @@ +// import type { Redis } from 'ioredis' + +import { CACHE_PREFIX } from 'common/enums' +import { genCacheKey } from 'connectors' + +export const checkOperationLimit = async ({ + user, + operation, + limit, + period, + redis, +}: { + user: string + operation: string + limit: number + period: number + redis: any // Redis +}) => { + const cacheKey = genCacheKey({ + id: user, + field: operation, + prefix: CACHE_PREFIX.OPERATION_LOG, + }) + + const operationLog = await redis.lrange(cacheKey, 0, -1) + + // timestamp in seconds + const current = Math.floor(Date.now() / 1000) + + // no record + if (!operationLog) { + // create + redis.lpush(cacheKey, current).then(() => { + redis.expire(cacheKey, period) + }) + + // pass + return true + } + + // count times within period + const cutoff = current - period + let times = 0 + for (const timestamp of operationLog) { + if (parseInt(timestamp, 10) >= cutoff) { + times += 1 + } else { + break + } + } + + // over limit + if (times >= limit) { + return false + } + + // add, trim, update expiration + redis.lpush(cacheKey, current) + redis.ltrim(cacheKey, 0, times) + redis.expire(cacheKey, period) + + // pass + return true +} diff --git a/src/mutations/article/publishArticle.ts b/src/mutations/article/publishArticle.ts index f695c9782..a47cfb7e6 100644 --- a/src/mutations/article/publishArticle.ts +++ b/src/mutations/article/publishArticle.ts @@ -1,13 +1,19 @@ import type { GQLMutationResolvers } from 'definitions' -import { PUBLISH_STATE, USER_STATE } from 'common/enums' import { + PUBLISH_ARTICLE_RATE_LIMIT, + PUBLISH_ARTICLE_RATE_PERIOD, + PUBLISH_STATE, + USER_STATE, +} from 'common/enums' +import { + ActionLimitExceededError, DraftNotFoundError, ForbiddenByStateError, ForbiddenError, UserInputError, } from 'common/errors' -import { fromGlobalId } from 'common/utils' +import { checkOperationLimit, fromGlobalId } from 'common/utils' const resolver: GQLMutationResolvers['publishArticle'] = async ( _, @@ -18,6 +24,7 @@ const resolver: GQLMutationResolvers['publishArticle'] = async ( draftService, atomService, queues: { publicationQueue }, + connections: { redis }, }, } ) => { @@ -50,6 +57,21 @@ const resolver: GQLMutationResolvers['publishArticle'] = async ( throw new UserInputError('content is required') } + const fieldName = 'publishArticle' + const pass = await checkOperationLimit({ + user: viewer.id || viewer.ip, + operation: fieldName, + limit: viewer?.publishRate?.limit ?? PUBLISH_ARTICLE_RATE_LIMIT, + period: viewer?.publishRate?.period ?? PUBLISH_ARTICLE_RATE_PERIOD, + redis, // : connections.redis, + }) + + if (!pass) { + throw new ActionLimitExceededError( + `rate exceeded for operation ${fieldName}` + ) + } + if ( draft.publishState === PUBLISH_STATE.pending || (draft.archived && isPublished) diff --git a/src/types/directives/rateLimit.ts b/src/types/directives/rateLimit.ts index 4042a28f8..7fdec8232 100644 --- a/src/types/directives/rateLimit.ts +++ b/src/types/directives/rateLimit.ts @@ -1,71 +1,8 @@ -import type { Redis } from 'ioredis' - import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils' import { defaultFieldResolver, GraphQLSchema } from 'graphql' -import { CACHE_PREFIX } from 'common/enums' import { ActionLimitExceededError } from 'common/errors' -import { genCacheKey } from 'connectors' - -const checkOperationLimit = async ({ - user, - operation, - limit, - period, - redis, -}: { - user: string - operation: string - limit: number - period: number - redis: Redis -}) => { - const cacheKey = genCacheKey({ - id: user, - field: operation, - prefix: CACHE_PREFIX.OPERATION_LOG, - }) - - const operationLog = await redis.lrange(cacheKey, 0, -1) - - // timestamp in seconds - const current = Math.floor(Date.now() / 1000) - - // no record - if (!operationLog) { - // create - redis.lpush(cacheKey, current).then(() => { - redis.expire(cacheKey, period) - }) - - // pass - return true - } - - // count times within period - const cutoff = current - period - let times = 0 - for (const timestamp of operationLog) { - if (parseInt(timestamp, 10) >= cutoff) { - times += 1 - } else { - break - } - } - - // over limit - if (times >= limit) { - return false - } - - // add, trim, update expiration - redis.lpush(cacheKey, current) - redis.ltrim(cacheKey, 0, times) - redis.expire(cacheKey, period) - - // pass - return true -} +import { checkOperationLimit } from 'common/utils' export const rateLimitDirective = (directiveName = 'rateLimit') => ({ typeDef: `"Rate limit within a given period of time, in seconds"