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 267ef51
Show file tree
Hide file tree
Showing 6 changed files with 102 additions and 61 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')
})
}
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
59 changes: 59 additions & 0 deletions src/common/utils/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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
}
24 changes: 23 additions & 1 deletion src/mutations/article/publishArticle.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import type { GQLMutationResolvers } from 'definitions'
import type { Redis } from 'ioredis'

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 {
DraftNotFoundError,
ForbiddenByStateError,
ForbiddenError,
UserInputError,
} from 'common/errors'
import { fromGlobalId } from 'common/utils'
import { checkOperationLimit } from 'types/directives'

const resolver: GQLMutationResolvers['publishArticle'] = async (
_,
Expand All @@ -18,6 +25,7 @@ const resolver: GQLMutationResolvers['publishArticle'] = async (
draftService,
atomService,
queues: { publicationQueue },
redis,
},
}
) => {
Expand Down Expand Up @@ -50,6 +58,20 @@ const resolver: GQLMutationResolvers['publishArticle'] = async (
throw new UserInputError('content is required')
}

const pass = await checkOperationLimit({
user: viewer.id || viewer.ip,
operation: 'publishArticle',
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
61 changes: 1 addition & 60 deletions src/types/directives/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,68 +5,9 @@ import { defaultFieldResolver, GraphQLSchema } from 'graphql'

import { CACHE_PREFIX } from 'common/enums'
import { ActionLimitExceededError } from 'common/errors'
import { checkOperationLimit } from 'common/utils'
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
}

export const rateLimitDirective = (directiveName = 'rateLimit') => ({
typeDef: `"Rate limit within a given period of time, in seconds"
directive @${directiveName}(period: Int!, limit: Int!) on FIELD_DEFINITION`,
Expand Down

0 comments on commit 267ef51

Please sign in to comment.