Skip to content

Commit

Permalink
feat(publish-quota): per user publishArticle quota
Browse files Browse the repository at this point in the history
resolves #3842
  • Loading branch information
49659410+tx0c committed Apr 16, 2024
1 parent 6f15387 commit 44ff2a0
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 67 deletions.
13 changes: 13 additions & 0 deletions db/migrations/20240417164251_alter_user_add_publish_rate.js
Original file line number Diff line number Diff line change
@@ -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')
})
}
9 changes: 9 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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!

Expand Down Expand Up @@ -3004,6 +3007,12 @@ input UpdateUserRoleInput {
role: UserRole!
}

input UpdateUserPublishRateInput {
id: ID!
limit: Int!
period: Int!
}

input UpdateUserExtraInput {
id: ID!
referralCode: String
Expand Down
5 changes: 5 additions & 0 deletions src/common/enums/user.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isProd } from 'common/environment'

export const USER_STATE = {
frozen: 'frozen',
active: 'active',
Expand All @@ -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;
1 change: 1 addition & 0 deletions src/common/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 _.
Expand Down
64 changes: 64 additions & 0 deletions src/common/utils/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions src/definitions/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,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. */
Expand Down Expand Up @@ -2157,6 +2159,10 @@ export type GQLMutationUpdateUserInfoArgs = {
input: GQLUpdateUserInfoInput
}

export type GQLMutationUpdateUserPublishRateArgs = {
input: GQLUpdateUserPublishRateInput
}

export type GQLMutationUpdateUserRoleArgs = {
input: GQLUpdateUserRoleInput
}
Expand Down Expand Up @@ -3585,6 +3591,12 @@ export type GQLUpdateUserInfoInput = {
userName?: InputMaybe<Scalars['String']['input']>
}

export type GQLUpdateUserPublishRateInput = {
id: Scalars['ID']['input']
limit: Scalars['Int']['input']
period: Scalars['Int']['input']
}

export type GQLUpdateUserRoleInput = {
id: Scalars['ID']['input']
role: GQLUserRole
Expand Down Expand Up @@ -4801,6 +4813,7 @@ export type GQLResolversTypes = ResolversObject<{
UpdateTagSettingType: GQLUpdateTagSettingType
UpdateUserExtraInput: GQLUpdateUserExtraInput
UpdateUserInfoInput: GQLUpdateUserInfoInput
UpdateUserPublishRateInput: GQLUpdateUserPublishRateInput
UpdateUserRoleInput: GQLUpdateUserRoleInput
UpdateUserStateInput: GQLUpdateUserStateInput
Upload: ResolverTypeWrapper<Scalars['Upload']['output']>
Expand Down Expand Up @@ -5254,6 +5267,7 @@ export type GQLResolversParentTypes = ResolversObject<{
UpdateTagSettingInput: GQLUpdateTagSettingInput
UpdateUserExtraInput: GQLUpdateUserExtraInput
UpdateUserInfoInput: GQLUpdateUserInfoInput
UpdateUserPublishRateInput: GQLUpdateUserPublishRateInput
UpdateUserRoleInput: GQLUpdateUserRoleInput
UpdateUserStateInput: GQLUpdateUserStateInput
Upload: Scalars['Upload']['output']
Expand Down Expand Up @@ -7523,6 +7537,12 @@ export type GQLMutationResolvers<
ContextType,
RequireFields<GQLMutationUpdateUserInfoArgs, 'input'>
>
updateUserPublishRate?: Resolver<
GQLResolversTypes['User'],
ParentType,
ContextType,
RequireFields<GQLMutationUpdateUserPublishRateArgs, 'input'>
>
updateUserRole?: Resolver<
GQLResolversTypes['User'],
ParentType,
Expand Down
3 changes: 2 additions & 1 deletion src/definitions/user.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ interface UserBase {
currency: 'HKD' | 'TWD' | 'USD' | null
profileCover?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra: any | null // jsonb saved here
publishRate?: any | null // jsonb saved here
extra?: any | null // jsonb saved here
remark: string | null
createdAt: Date
updatedAt: Date
Expand Down
26 changes: 24 additions & 2 deletions src/mutations/article/publishArticle.ts
Original file line number Diff line number Diff line change
@@ -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 (
_,
Expand All @@ -18,6 +24,7 @@ const resolver: GQLMutationResolvers['publishArticle'] = async (
draftService,
atomService,
queues: { publicationQueue },
connections: { redis },
},
}
) => {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/mutations/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -68,6 +69,7 @@ export default {
updateUserState,
updateUserRole,
updateUserExtra,
updateUserPublishRate,
setEmail,
setUserName,
setPassword,
Expand Down
34 changes: 34 additions & 0 deletions src/mutations/user/updateUserPublishRate.ts
Original file line number Diff line number Diff line change
@@ -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
65 changes: 1 addition & 64 deletions src/types/directives/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -829,6 +832,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
Expand Down

0 comments on commit 44ff2a0

Please sign in to comment.