Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions db/migrations/20240531164251_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 @@ -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!

Expand Down Expand Up @@ -2879,6 +2882,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this rate confirmed with the CC team?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was the default before:

"hotfix(publish-rate): reduce rate of publishArticle"

This reverts commit 2717c88.

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: 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 @@ -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. */
Expand Down Expand Up @@ -2108,6 +2110,10 @@ export type GQLMutationUpdateUserInfoArgs = {
input: GQLUpdateUserInfoInput
}

export type GQLMutationUpdateUserPublishRateArgs = {
input: GQLUpdateUserPublishRateInput
}

export type GQLMutationUpdateUserRoleArgs = {
input: GQLUpdateUserRoleInput
}
Expand Down Expand Up @@ -3468,6 +3474,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 @@ -4658,6 +4670,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 @@ -5098,6 +5111,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 @@ -7322,6 +7336,12 @@ export type GQLMutationResolvers<
ContextType,
RequireFields<GQLMutationUpdateUserInfoArgs, 'input'>
>
updateUserPublishRate?: Resolver<
GQLResolversTypes['User'],
ParentType,
ContextType,
RequireFields<GQLMutationUpdateUserPublishRateArgs, 'input'>
>
updateUserRole?: Resolver<
GQLResolversTypes['User'],
ParentType,
Expand Down
1 change: 1 addition & 0 deletions src/definitions/user.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

viewer.ip will never be used as only logged-in user can publish articles by now?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And as opercation limit is checked here, rateLimitDirective (@ratelimit) on publishArticle in schema need to be removed

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(

@robertu7 robertu7 Apr 22, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add unit test for this mutation changes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in src/types/__test__/1/article.test.ts

`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
24 changes: 24 additions & 0 deletions src/types/__test__/1/article.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/types/article.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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}")
Expand Down
Loading