From c444559b6edad50bdbf6a4cf8de2ba2d731ea558 Mon Sep 17 00:00:00 2001 From: 49659410+tx0c <> Date: Tue, 16 Apr 2024 07:23:23 +0000 Subject: [PATCH 1/2] feat(publish-quota): per user publishArticle quota resolves #3842 --- ...40531164251_alter_user_add_publish_rate.js | 13 ++++ schema.graphql | 9 +++ src/common/enums/user.ts | 5 ++ src/common/utils/index.ts | 1 + src/common/utils/rateLimit.ts | 64 ++++++++++++++++++ src/definitions/schema.d.ts | 20 ++++++ src/definitions/user.d.ts | 1 + src/mutations/article/publishArticle.ts | 26 +++++++- src/mutations/user/index.ts | 2 + src/mutations/user/updateUserPublishRate.ts | 34 ++++++++++ src/types/__test__/1/article.test.ts | 24 +++++++ src/types/directives/rateLimit.ts | 65 +------------------ src/types/user.ts | 9 +++ 13 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 db/migrations/20240531164251_alter_user_add_publish_rate.js create mode 100644 src/common/utils/rateLimit.ts create mode 100644 src/mutations/user/updateUserPublishRate.ts diff --git a/db/migrations/20240531164251_alter_user_add_publish_rate.js b/db/migrations/20240531164251_alter_user_add_publish_rate.js new file mode 100644 index 000000000..39d884b1e --- /dev/null +++ b/db/migrations/20240531164251_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/schema.graphql b/schema.graphql index c1be83c35..51b5d3661 100644 --- a/schema.graphql +++ b/schema.graphql @@ -253,6 +253,9 @@ type Mutation { """Update state of a user, used in OSS.""" updateUserRole(input: UpdateUserRoleInput!): User! + """Update allowed publish rate of a user, used in OSS.""" + updateUserPublishRate(input: UpdateUserPublishRateInput!): User! + """Update referralCode of a user, used in OSS.""" updateUserExtra(input: UpdateUserExtraInput!): User! @@ -2879,6 +2882,12 @@ input UpdateUserRoleInput { role: UserRole! } +input UpdateUserPublishRateInput { + id: ID! + limit: Int! + period: Int! +} + input UpdateUserExtraInput { id: ID! referralCode: String 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 824211d0d..a2ed45cb6 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..40f3b423a --- /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: 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/definitions/schema.d.ts b/src/definitions/schema.d.ts index 58aa401b5..c2e3de59d 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -1736,6 +1736,8 @@ export type GQLMutation = { updateUserExtra: GQLUser /** Update user information. */ updateUserInfo: GQLUser + /** Update allowed publish rate of a user, used in OSS. */ + updateUserPublishRate: GQLUser /** Update state of a user, used in OSS. */ updateUserRole: GQLUser /** Update state of a user, used in OSS. */ @@ -2108,6 +2110,10 @@ export type GQLMutationUpdateUserInfoArgs = { input: GQLUpdateUserInfoInput } +export type GQLMutationUpdateUserPublishRateArgs = { + input: GQLUpdateUserPublishRateInput +} + export type GQLMutationUpdateUserRoleArgs = { input: GQLUpdateUserRoleInput } @@ -3468,6 +3474,12 @@ export type GQLUpdateUserInfoInput = { userName?: InputMaybe } +export type GQLUpdateUserPublishRateInput = { + id: Scalars['ID']['input'] + limit: Scalars['Int']['input'] + period: Scalars['Int']['input'] +} + export type GQLUpdateUserRoleInput = { id: Scalars['ID']['input'] role: GQLUserRole @@ -4658,6 +4670,7 @@ export type GQLResolversTypes = ResolversObject<{ UpdateTagSettingType: GQLUpdateTagSettingType UpdateUserExtraInput: GQLUpdateUserExtraInput UpdateUserInfoInput: GQLUpdateUserInfoInput + UpdateUserPublishRateInput: GQLUpdateUserPublishRateInput UpdateUserRoleInput: GQLUpdateUserRoleInput UpdateUserStateInput: GQLUpdateUserStateInput Upload: ResolverTypeWrapper @@ -5098,6 +5111,7 @@ export type GQLResolversParentTypes = ResolversObject<{ UpdateTagSettingInput: GQLUpdateTagSettingInput UpdateUserExtraInput: GQLUpdateUserExtraInput UpdateUserInfoInput: GQLUpdateUserInfoInput + UpdateUserPublishRateInput: GQLUpdateUserPublishRateInput UpdateUserRoleInput: GQLUpdateUserRoleInput UpdateUserStateInput: GQLUpdateUserStateInput Upload: Scalars['Upload']['output'] @@ -7322,6 +7336,12 @@ export type GQLMutationResolvers< ContextType, RequireFields > + updateUserPublishRate?: Resolver< + GQLResolversTypes['User'], + ParentType, + ContextType, + RequireFields + > updateUserRole?: Resolver< GQLResolversTypes['User'], ParentType, diff --git a/src/definitions/user.d.ts b/src/definitions/user.d.ts index 1ead785d2..7c104e681 100644 --- a/src/definitions/user.d.ts +++ b/src/definitions/user.d.ts @@ -30,6 +30,7 @@ interface UserBase { currency: 'HKD' | 'TWD' | 'USD' | null profileCover?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any + publishRate: any | null // jsonb saved here extra: any | null // jsonb saved here remark: string | null createdAt: Date 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/mutations/user/index.ts b/src/mutations/user/index.ts index d51e5cb79..a56b5eacb 100644 --- a/src/mutations/user/index.ts +++ b/src/mutations/user/index.ts @@ -31,6 +31,7 @@ import unbindLikerId from './unbindLikerId' import updateNotificationSetting from './updateNotificationSetting' import updateUserExtra from './updateUserExtra' import updateUserInfo from './updateUserInfo' +import updateUserPublishRate from './updateUserPublishRate' import updateUserRole from './updateUserRole' import updateUserState from './updateUserState' import userLogin from './userLogin' @@ -68,6 +69,7 @@ export default { updateUserState, updateUserRole, updateUserExtra, + updateUserPublishRate, setEmail, setUserName, setPassword, diff --git a/src/mutations/user/updateUserPublishRate.ts b/src/mutations/user/updateUserPublishRate.ts new file mode 100644 index 000000000..a572764b1 --- /dev/null +++ b/src/mutations/user/updateUserPublishRate.ts @@ -0,0 +1,34 @@ +import type { GQLMutationResolvers } from 'definitions' + +import _isEmpty from 'lodash/isEmpty' + +import { UserInputError } from 'common/errors' +import { fromGlobalId } from 'common/utils' + +const resolver: GQLMutationResolvers['updateUserPublishRate'] = async ( + _, + { input: { id, limit, period } }, + { dataSources: { atomService } } +) => { + const { id: dbId } = fromGlobalId(id) + + // validate data ranges? + const publishRate = { + limit, + period, + } + + if (_isEmpty(publishRate)) { + throw new UserInputError('bad request') + } + + const user = await atomService.update({ + table: 'user', + where: { id: dbId }, + data: { publishRate }, + }) + + return user +} + +export default resolver diff --git a/src/types/__test__/1/article.test.ts b/src/types/__test__/1/article.test.ts index 11ab40584..34de8581d 100644 --- a/src/types/__test__/1/article.test.ts +++ b/src/types/__test__/1/article.test.ts @@ -279,6 +279,30 @@ describe('publish article', () => { expect(publishState).toBe(PUBLISH_STATE.pending) expect(article).toBeNull() }) + test('publish again should trigger rate limit', async () => { + jest.setTimeout(10000) + const draft = { + title: Math.random().toString(), + content: Math.random().toString(), + } + const { id } = await putDraft({ draft }, connections) + const { publishState, article } = await publishArticle({ id }, connections) + expect(publishState).toBe(PUBLISH_STATE.pending) + expect(article).toBeNull() + + const { id: nextDraftId } = await putDraft( + { + draft: { + title: Math.random().toString(), + content: Math.random().toString(), + }, + }, + connections + ) + const res2Publish = await publishArticle({ id }, connections) + expect(res2Publish.publishState).toBe(PUBLISH_STATE.pending) + expect(res2Publish.article).toBeNull() + }) test('create a draft & publish with iscn', async () => { jest.setTimeout(10000) 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" diff --git a/src/types/user.ts b/src/types/user.ts index 7aeb3f1ff..3f8a2b583 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -113,6 +113,9 @@ export default /* GraphQL */ ` "Update state of a user, used in OSS." updateUserRole(input: UpdateUserRoleInput!): User! @auth(mode: "${AUTH_MODE.admin}") @purgeCache(type: "${NODE_TYPES.User}") + "Update allowed publish rate of a user, used in OSS." + updateUserPublishRate(input: UpdateUserPublishRateInput!): User! @auth(mode: "${AUTH_MODE.admin}") @purgeCache(type: "${NODE_TYPES.User}") + "Update referralCode of a user, used in OSS." updateUserExtra(input: UpdateUserExtraInput!): User! @auth(mode: "${AUTH_MODE.admin}") @purgeCache(type: "${NODE_TYPES.User}") @@ -817,6 +820,12 @@ export default /* GraphQL */ ` role: UserRole! } + input UpdateUserPublishRateInput { + id: ID! + limit: Int! @constraint(min: 1, max: 1000) ## how many allowed + period: Int! @constraint(min: 60, max: 3600) ## in how many seconds + } + input UpdateUserExtraInput { id: ID! referralCode: String ## user can change it once only, from null to a value From 09410fe2c9fd38af4393115eb08d6f4ccf9c88cc Mon Sep 17 00:00:00 2001 From: TomasC <49659410+tx0c@users.noreply.github.com> Date: Wed, 29 May 2024 23:01:29 +0000 Subject: [PATCH 2/2] Revert "hotfix(publish-rate): reduce rate of publishArticle" This reverts commit 2717c883658a586b8ea6c1adc3cd787e04500526. --- src/types/article.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/article.ts b/src/types/article.ts index 7d52f8ecb..b191d1cb3 100644 --- a/src/types/article.ts +++ b/src/types/article.ts @@ -1,7 +1,7 @@ import { AUTH_MODE, CACHE_TTL, NODE_TYPES, SCOPE_GROUP } from 'common/enums' import { isProd } from 'common/environment' -const PUBLISH_ARTICLE_RATE_LIMIT = isProd ? 1 : 100 +const PUBLISH_ARTICLE_RATE_LIMIT = isProd ? 10 : 1000 export default /* GraphQL */ ` extend type Query { @@ -13,7 +13,7 @@ export default /* GraphQL */ ` # Article # ############## "Publish an article onto IPFS." - publishArticle(input: PublishArticleInput!): Draft! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level2}") @purgeCache(type: "${NODE_TYPES.Draft}") @rateLimit(limit:${PUBLISH_ARTICLE_RATE_LIMIT}, period:720) + publishArticle(input: PublishArticleInput!): Draft! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level2}") @purgeCache(type: "${NODE_TYPES.Draft}") @rateLimit(limit:${PUBLISH_ARTICLE_RATE_LIMIT}, period:7200) "Edit an article." editArticle(input: EditArticleInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level3}") @purgeCache(type: "${NODE_TYPES.Article}")