diff --git a/.env.example b/.env.example index b459706..d7528bb 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ POSTGRES_USER=pokopia POSTGRES_PASSWORD=pokopia DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia BACKEND_PORT=3001 +TRUST_PROXY=false FRONTEND_ORIGIN=http://localhost:20015 APP_ORIGIN=http://localhost:20015 VITE_API_BASE_URL=http://localhost:3001 diff --git a/DESIGN.md b/DESIGN.md index b3cb905..eed73d7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -202,6 +202,31 @@ - 当前版本不提供积分奖励、排行榜、邀请邮件发送、邀请制注册限制、后台统计或公开邀请人资料页。 - Referral API 对外只返回当前用户自己的 Referral 摘要,不返回被邀请用户邮箱、token/hash、内部审计字段或被邀请用户明细。 +## 滥用防护与限流 + +- 后端使用 `@fastify/rate-limit` 在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。 +- Fastify 默认不信任代理转发 IP;部署在可信反向代理后方时,可设置 `TRUST_PROXY=true`,让 IP 限流使用代理解析后的客户端 IP。 +- 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key,用户限流使用当前登录用户 ID,路由限流使用 HTTP method + route pattern。 +- 触发限流时 API 返回 429 和本地化通用错误文案,并带 `Retry-After` 与 rate limit headers;响应不得返回邮箱、用户 ID、内部 key、token/hash 或调试信息。 +- 认证入口限流: + - 注册、登录、验证邮箱、请求重置密码、提交重置密码均按 IP + 路由限制为 20 次 / 10 分钟。 + - 登录额外按邮箱限制为 5 次 / 15 分钟。 + - 注册额外按邮箱限制为 3 次 / 1 小时。 + - 请求重置密码额外按邮箱限制为 3 次 / 1 小时,并按 IP + 路由限制为 10 次 / 15 分钟。 + - 提交重置密码额外按 IP + 路由限制为 10 次 / 15 分钟。 +- 已登录保护路由按 IP + 路由限制为 120 次 / 10 分钟,避免单一来源反复触发鉴权查询。 +- 写入路由通用限流: + - 写入路由按 IP + 路由限制为 90 次 / 10 分钟。 + - 写入路由按用户 ID + 路由限制为 30 次 / 10 分钟。 +- 用户账号资料写入按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。 +- Wiki 内容写入(Pokemon、物品、材料单、栖息地、每日 CheckList、配置项和排序)按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。 +- 管理写入(用户角色、角色、权限、语言和系统文案)按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。 +- 上传按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。 +- Community 写入: + - Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。 + - Life reaction 写入按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。 +- Pokemon Fetch 数据和图片候选查询按 IP + 路由限制为 60 次 / 10 分钟,按用户 ID 限制为 60 次 / 10 分钟,按用户 ID + 路由限制为 30 次 / 10 分钟,并有 1 秒冷却时间。 + ## Community 编辑与审计 - 已验证且拥有对应权限的用户可以通过前台或管理入口编辑 Wiki 内容。 diff --git a/backend/package.json b/backend/package.json index 3af8ec8..0dfd1fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "dependencies": { "@fastify/cors": "latest", "@fastify/multipart": "^10.0.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.1.3", "fastify": "latest", "pg": "latest" diff --git a/backend/src/server.ts b/backend/src/server.ts index 12164f9..6490689 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,8 +1,10 @@ import cors from '@fastify/cors'; import multipart, { type MultipartFile } from '@fastify/multipart'; +import rateLimit from '@fastify/rate-limit'; import fastifyStatic from '@fastify/static'; import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; +import { createHash } from 'node:crypto'; import { mkdir } from 'node:fs/promises'; import { changeCurrentUserPassword, @@ -111,7 +113,8 @@ import { } from './uploads.ts'; const app = Fastify({ - logger: true + logger: true, + trustProxy: process.env.TRUST_PROXY === 'true' }); await app.register(cors, { @@ -120,6 +123,11 @@ await app.register(cors, { origin: process.env.FRONTEND_ORIGIN ?? true }); +await app.register(rateLimit, { + global: false, + hook: 'preHandler' +}); + await mkdir(uploadRoot, { recursive: true }); await app.register(multipart, { limits: { @@ -149,6 +157,10 @@ app.setErrorHandler(async (error, _request, reply) => { return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') }); } + if (pgError.statusCode === 429) { + return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') }); + } + if (pgError.statusCode && pgError.statusCode < 500) { return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) }); } @@ -182,10 +194,243 @@ function serverMessage( | 'verifyEmailFirst' | 'permissionDenied' | 'notFound' + | 'rateLimited' ): Promise { return systemMessage(locale, `server.errors.${key}`); } +type RateLimitCheck = ReturnType; +type RateLimitResult = Awaited>; +type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite'; +type RateLimitedRequest = FastifyRequest & { + rateLimitUserId?: number; +}; + +function hashRateLimitPart(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 32); +} + +function routeRateLimitPart(request: FastifyRequest): string { + return `${request.method}:${request.routeOptions.url ?? request.url.split('?')[0]}`; +} + +function emailRateLimitPart(request: FastifyRequest): string { + const body = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; + const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; + return hashRateLimitPart(email || 'missing-email'); +} + +function userRateLimitPart(request: FastifyRequest): string { + return String((request as RateLimitedRequest).rateLimitUserId ?? 'anonymous'); +} + +function userRouteRateLimitKey(scope: string, request: FastifyRequest): string { + return `${scope}:user:${userRateLimitPart(request)}:route:${routeRateLimitPart(request)}`; +} + +function ipRouteRateLimitKey(scope: string, request: FastifyRequest): string { + return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`; +} + +const authRouteIpRateLimit = app.createRateLimit({ + max: 20, + timeWindow: '10 minutes', + keyGenerator: (request) => ipRouteRateLimitKey('auth', request) +}); +const loginEmailRateLimit = app.createRateLimit({ + max: 5, + timeWindow: '15 minutes', + keyGenerator: (request) => `auth:login:email:${emailRateLimitPart(request)}` +}); +const registerEmailRateLimit = app.createRateLimit({ + max: 3, + timeWindow: '1 hour', + keyGenerator: (request) => `auth:register:email:${emailRateLimitPart(request)}` +}); +const passwordResetEmailRateLimit = app.createRateLimit({ + max: 3, + timeWindow: '1 hour', + keyGenerator: (request) => `auth:password-reset:email:${emailRateLimitPart(request)}` +}); +const passwordResetRouteIpRateLimit = app.createRateLimit({ + max: 10, + timeWindow: '15 minutes', + keyGenerator: (request) => ipRouteRateLimitKey('auth:password-reset', request) +}); +const protectedRouteIpRateLimit = app.createRateLimit({ + max: 120, + timeWindow: '10 minutes', + keyGenerator: (request) => ipRouteRateLimitKey('protected', request) +}); +const writeRouteIpRateLimit = app.createRateLimit({ + max: 90, + timeWindow: '10 minutes', + keyGenerator: (request) => ipRouteRateLimitKey('write', request) +}); +const userRouteWriteRateLimit = app.createRateLimit({ + max: 30, + timeWindow: '10 minutes', + keyGenerator: (request) => userRouteRateLimitKey('write', request) +}); +const fetchRouteIpRateLimit = app.createRateLimit({ + max: 60, + timeWindow: '10 minutes', + keyGenerator: (request) => ipRouteRateLimitKey('fetch', request) +}); +const userRouteFetchRateLimit = app.createRateLimit({ + max: 30, + timeWindow: '10 minutes', + keyGenerator: (request) => userRouteRateLimitKey('fetch', request) +}); + +const userRateLimitPolicies: Record = { + accountWrite: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 20, + timeWindow: '1 hour', + keyGenerator: (request) => `account-write:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '5 seconds', + keyGenerator: (request) => `account-write-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ], + adminWrite: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 120, + timeWindow: '1 hour', + keyGenerator: (request) => `admin-write:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '2 seconds', + keyGenerator: (request) => `admin-write-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ], + communityReaction: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 120, + timeWindow: '1 hour', + keyGenerator: (request) => `community-reaction:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '1 second', + keyGenerator: (request) => `community-reaction-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ], + communityWrite: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 60, + timeWindow: '1 hour', + keyGenerator: (request) => `community-write:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '5 seconds', + keyGenerator: (request) => `community-write-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ], + fetch: [ + fetchRouteIpRateLimit, + app.createRateLimit({ + max: 60, + timeWindow: '10 minutes', + keyGenerator: (request) => `fetch:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '1 second', + keyGenerator: (request) => `fetch-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteFetchRateLimit + ], + upload: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 20, + timeWindow: '1 hour', + keyGenerator: (request) => `upload:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '30 seconds', + keyGenerator: (request) => `upload-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ], + wikiWrite: [ + writeRouteIpRateLimit, + app.createRateLimit({ + max: 120, + timeWindow: '1 hour', + keyGenerator: (request) => `wiki-write:user:${userRateLimitPart(request)}` + }), + app.createRateLimit({ + max: 1, + timeWindow: '2 seconds', + keyGenerator: (request) => `wiki-write-cooldown:user:${userRateLimitPart(request)}` + }), + userRouteWriteRateLimit + ] +}; + +async function sendRateLimited( + request: FastifyRequest, + reply: FastifyReply, + result: Extract +): Promise { + const retryAfter = Math.max(1, result.ttlInSeconds); + reply.header('retry-after', retryAfter); + reply.header('x-ratelimit-limit', result.max); + reply.header('x-ratelimit-remaining', 0); + reply.header('x-ratelimit-reset', retryAfter); + reply.code(result.isBanned ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') }); + return false; +} + +async function enforceRateLimits( + request: FastifyRequest, + reply: FastifyReply, + checks: RateLimitCheck[] +): Promise { + for (const check of checks) { + const result = await check(request); + if (!result.isAllowed && result.isExceeded) { + return sendRateLimited(request, reply, result); + } + } + + return true; +} + +async function enforceAuthRateLimits( + request: FastifyRequest, + reply: FastifyReply, + checks: RateLimitCheck[] +): Promise { + return enforceRateLimits(request, reply, [authRouteIpRateLimit, ...checks]); +} + +async function enforceUserRateLimits( + request: FastifyRequest, + reply: FastifyReply, + user: AuthUser, + policy: RateLimitPolicy +): Promise { + (request as RateLimitedRequest).rateLimitUserId = user.id; + return enforceRateLimits(request, reply, userRateLimitPolicies[policy]); +} + function badRequest(message: string): Error & { statusCode: number } { const error = new Error(message) as Error & { statusCode: number }; error.statusCode = 400; @@ -197,6 +442,10 @@ async function notFound(reply: FastifyReply, request: FastifyRequest) { } async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return null; + } + const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; const locale = requestLocale(request); @@ -250,6 +499,34 @@ async function requireAnyPermission( return user; } +async function requirePermissionWithRateLimits( + request: FastifyRequest, + reply: FastifyReply, + permissionKey: string, + policy: RateLimitPolicy +): Promise { + const user = await requirePermission(request, reply, permissionKey); + if (!user || !(await enforceUserRateLimits(request, reply, user, policy))) { + return null; + } + + return user; +} + +async function requireAnyPermissionWithRateLimits( + request: FastifyRequest, + reply: FastifyReply, + permissionKeys: string[], + policy: RateLimitPolicy +): Promise { + const user = await requireAnyPermission(request, reply, permissionKeys); + if (!user || !(await enforceUserRateLimits(request, reply, user, policy))) { + return null; + } + + return user; +} + async function optionalUser(request: FastifyRequest): Promise { const token = getBearerToken(request.headers.authorization); if (!token) { @@ -263,23 +540,51 @@ async function optionalUser(request: FastifyRequest): Promise { } } -app.post('/api/auth/register', async (request, reply) => - reply.code(201).send(await registerUser(request.body as Record, requestLocale(request))) -); +app.post('/api/auth/register', async (request, reply) => { + if (!(await enforceAuthRateLimits(request, reply, [registerEmailRateLimit]))) { + return; + } -app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record, requestLocale(request))); + return reply.code(201).send(await registerUser(request.body as Record, requestLocale(request))); +}); -app.post('/api/auth/login', async (request) => loginUser(request.body as Record, requestLocale(request))); +app.post('/api/auth/verify-email', async (request, reply) => { + if (!(await enforceAuthRateLimits(request, reply, []))) { + return; + } -app.post('/api/auth/request-password-reset', async (request) => - requestPasswordReset(request.body as Record, requestLocale(request)) -); + return verifyEmail(request.body as Record, requestLocale(request)); +}); -app.post('/api/auth/reset-password', async (request) => - resetPassword(request.body as Record, requestLocale(request)) -); +app.post('/api/auth/login', async (request, reply) => { + if (!(await enforceAuthRateLimits(request, reply, [loginEmailRateLimit]))) { + return; + } + + return loginUser(request.body as Record, requestLocale(request)); +}); + +app.post('/api/auth/request-password-reset', async (request, reply) => { + if (!(await enforceAuthRateLimits(request, reply, [passwordResetEmailRateLimit, passwordResetRouteIpRateLimit]))) { + return; + } + + return requestPasswordReset(request.body as Record, requestLocale(request)); +}); + +app.post('/api/auth/reset-password', async (request, reply) => { + if (!(await enforceAuthRateLimits(request, reply, [passwordResetRouteIpRateLimit]))) { + return; + } + + return resetPassword(request.body as Record, requestLocale(request)); +}); app.get('/api/auth/me', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; @@ -291,6 +596,10 @@ app.get('/api/auth/me', async (request, reply) => { }); app.patch('/api/auth/me', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; @@ -298,11 +607,19 @@ app.patch('/api/auth/me', async (request, reply) => { return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); } + if (!(await enforceUserRateLimits(request, reply, user, 'accountWrite'))) { + return; + } + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) }; }); app.patch('/api/auth/me/password', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; @@ -310,11 +627,19 @@ app.patch('/api/auth/me/password', async (request, reply) => { return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); } + if (!(await enforceUserRateLimits(request, reply, user, 'accountWrite'))) { + return; + } + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; return changeCurrentUserPassword(user.id, payload, token, requestLocale(request)); }); app.get('/api/auth/referral', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; @@ -340,7 +665,7 @@ app.get('/api/admin/users', async (request, reply) => { }); app.put('/api/admin/users/:id/roles', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.users.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.users.update', 'adminWrite'); if (!user) { return; } @@ -354,12 +679,12 @@ app.get('/api/admin/roles', async (request, reply) => { }); app.post('/api/admin/roles', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.roles.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.create', 'adminWrite'); return user ? reply.code(201).send(await createRole(request.body as Record)) : undefined; }); app.put('/api/admin/roles/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.roles.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.update', 'adminWrite'); if (!user) { return; } @@ -368,7 +693,7 @@ app.put('/api/admin/roles/:id', async (request, reply) => { }); app.put('/api/admin/roles/:id/permissions', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.roles.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.update', 'adminWrite'); if (!user) { return; } @@ -377,7 +702,7 @@ app.put('/api/admin/roles/:id/permissions', async (request, reply) => { }); app.delete('/api/admin/roles/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.roles.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.delete', 'adminWrite'); if (!user) { return; } @@ -392,12 +717,12 @@ app.get('/api/admin/permissions', async (request, reply) => { }); app.post('/api/admin/permissions', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.permissions.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.create', 'adminWrite'); return user ? reply.code(201).send(await createPermission(request.body as Record)) : undefined; }); app.put('/api/admin/permissions/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.permissions.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.update', 'adminWrite'); if (!user) { return; } @@ -406,7 +731,7 @@ app.put('/api/admin/permissions/:id', async (request, reply) => { }); app.delete('/api/admin/permissions/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.permissions.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.delete', 'adminWrite'); if (!user) { return; } @@ -469,14 +794,14 @@ app.get('/api/life-posts', async (request) => { }); app.post('/api/life-posts', async (request, reply) => { - const user = await requirePermission(request, reply, 'life.posts.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'life.posts.create', 'communityWrite'); return user ? reply.code(201).send(await createLifePost(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.post('/api/life-posts/:postId/comments', async (request, reply) => { - const user = await requirePermission(request, reply, 'life.comments.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.create', 'communityWrite'); if (!user) { return; } @@ -486,7 +811,7 @@ app.post('/api/life-posts/:postId/comments', async (request, reply) => { }); app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => { - const user = await requirePermission(request, reply, 'life.comments.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.create', 'communityWrite'); if (!user) { return; } @@ -501,7 +826,12 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, }); app.put('/api/life-posts/:id', async (request, reply) => { - const user = await requireAnyPermission(request, reply, ['life.posts.update', 'life.posts.update-any']); + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['life.posts.update', 'life.posts.update-any'], + 'communityWrite' + ); if (!user) { return; } @@ -517,7 +847,7 @@ app.put('/api/life-posts/:id', async (request, reply) => { }); app.put('/api/life-posts/:id/reaction', async (request, reply) => { - const user = await requirePermission(request, reply, 'life.reactions.set'); + const user = await requirePermissionWithRateLimits(request, reply, 'life.reactions.set', 'communityReaction'); if (!user) { return; } @@ -527,7 +857,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => { }); app.delete('/api/life-posts/:id/reaction', async (request, reply) => { - const user = await requirePermission(request, reply, 'life.reactions.set'); + const user = await requirePermissionWithRateLimits(request, reply, 'life.reactions.set', 'communityReaction'); if (!user) { return; } @@ -537,7 +867,12 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => { }); app.delete('/api/life-posts/:id', async (request, reply) => { - const user = await requireAnyPermission(request, reply, ['life.posts.delete', 'life.posts.delete-any']); + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['life.posts.delete', 'life.posts.delete-any'], + 'communityWrite' + ); if (!user) { return; } @@ -547,7 +882,12 @@ app.delete('/api/life-posts/:id', async (request, reply) => { }); app.delete('/api/life-comments/:id', async (request, reply) => { - const user = await requireAnyPermission(request, reply, ['life.comments.delete', 'life.comments.delete-any']); + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['life.comments.delete', 'life.comments.delete-any'], + 'communityWrite' + ); if (!user) { return; } @@ -563,7 +903,7 @@ app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply }); app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => { - const user = await requirePermission(request, reply, 'discussions.comments.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.create', 'communityWrite'); if (!user) { return; } @@ -579,7 +919,7 @@ app.post('/api/discussions/:entityType/:entityId/comments', async (request, repl }); app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => { - const user = await requirePermission(request, reply, 'discussions.comments.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.create', 'communityWrite'); if (!user) { return; } @@ -600,10 +940,12 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a }); app.delete('/api/discussions/comments/:id', async (request, reply) => { - const user = await requireAnyPermission(request, reply, [ - 'discussions.comments.delete', - 'discussions.comments.delete-any' - ]); + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['discussions.comments.delete', 'discussions.comments.delete-any'], + 'communityWrite' + ); if (!user) { return; } @@ -622,7 +964,7 @@ app.get('/api/pokemon', async (request) => ); app.get('/api/pokemon/fetch-options', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.fetch'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch'); return user ? listPokemonFetchOptions(request.query as Record, requestLocale(request)) : undefined; @@ -640,19 +982,19 @@ app.get('/api/pokemon/:id', async (request, reply) => { }); app.post('/api/pokemon', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.create', 'wikiWrite'); return user ? reply.code(201).send(await createPokemon(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.post('/api/pokemon/fetch', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.fetch'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch'); return user ? fetchPokemonData(request.body as Record, user.id) : undefined; }); app.post('/api/pokemon/image-options', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.fetch'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch'); return user ? fetchPokemonImageOptions(request.body as Record) : undefined; }); @@ -664,7 +1006,7 @@ app.post('/api/uploads/:entityType', async (request, reply) => { const permissionKey = entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload'; - const user = await requirePermission(request, reply, permissionKey); + const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload'); if (!user) { return; } @@ -684,7 +1026,7 @@ app.post('/api/uploads/:entityType', async (request, reply) => { }); app.put('/api/pokemon/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.update', 'wikiWrite'); if (!user) { return; } @@ -699,7 +1041,7 @@ app.put('/api/pokemon/:id', async (request, reply) => { }); app.delete('/api/pokemon/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.delete', 'wikiWrite'); if (!user) { return; } @@ -722,14 +1064,14 @@ app.get('/api/habitats/:id', async (request, reply) => { }); app.post('/api/habitats', async (request, reply) => { - const user = await requirePermission(request, reply, 'habitats.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'habitats.create', 'wikiWrite'); return user ? reply.code(201).send(await createHabitat(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/habitats/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'habitats.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'habitats.update', 'wikiWrite'); if (!user) { return; } @@ -744,7 +1086,7 @@ app.put('/api/habitats/:id', async (request, reply) => { }); app.delete('/api/habitats/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'habitats.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'habitats.delete', 'wikiWrite'); if (!user) { return; } @@ -769,14 +1111,14 @@ app.get('/api/items/:id', async (request, reply) => { }); app.post('/api/items', async (request, reply) => { - const user = await requirePermission(request, reply, 'items.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite'); return user ? reply.code(201).send(await createItem(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/items/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'items.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'items.update', 'wikiWrite'); if (!user) { return; } @@ -791,7 +1133,7 @@ app.put('/api/items/:id', async (request, reply) => { }); app.delete('/api/items/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'items.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'items.delete', 'wikiWrite'); if (!user) { return; } @@ -816,14 +1158,14 @@ app.get('/api/recipes/:id', async (request, reply) => { }); app.post('/api/recipes', async (request, reply) => { - const user = await requirePermission(request, reply, 'recipes.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'recipes.create', 'wikiWrite'); return user ? reply.code(201).send(await createRecipe(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/recipes/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'recipes.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'recipes.update', 'wikiWrite'); if (!user) { return; } @@ -838,7 +1180,7 @@ app.put('/api/recipes/:id', async (request, reply) => { }); app.delete('/api/recipes/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'recipes.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'recipes.delete', 'wikiWrite'); if (!user) { return; } @@ -848,7 +1190,7 @@ app.delete('/api/recipes/:id', async (request, reply) => { }); app.post('/api/admin/daily-checklist', async (request, reply) => { - const user = await requirePermission(request, reply, 'checklist.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite'); return user ? reply .code(201) @@ -857,12 +1199,12 @@ app.post('/api/admin/daily-checklist', async (request, reply) => { }); app.put('/api/admin/daily-checklist/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'checklist.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'checklist.order', 'wikiWrite'); return user ? reorderDailyChecklistItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/daily-checklist/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'checklist.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'checklist.update', 'wikiWrite'); if (!user) { return; } @@ -877,7 +1219,7 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => { }); app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'checklist.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'checklist.delete', 'wikiWrite'); if (!user) { return; } @@ -887,22 +1229,22 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { }); app.put('/api/admin/pokemon/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'pokemon.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite'); return user ? reorderPokemon(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/items/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'items.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite'); return user ? reorderItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/recipes/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'recipes.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite'); return user ? reorderRecipes(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/habitats/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'habitats.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'habitats.order', 'wikiWrite'); return user ? reorderHabitats(request.body as Record, user.id, requestLocale(request)) : undefined; }); @@ -912,17 +1254,17 @@ app.get('/api/admin/languages', async (request, reply) => { }); app.post('/api/admin/languages', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.languages.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.create', 'adminWrite'); return user ? reply.code(201).send(await createLanguage(request.body as Record)) : undefined; }); app.put('/api/admin/languages/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.languages.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.order', 'adminWrite'); return user ? reorderLanguages(request.body as Record) : undefined; }); app.put('/api/admin/languages/:code', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.languages.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.update', 'adminWrite'); if (!user) { return; } @@ -931,7 +1273,7 @@ app.put('/api/admin/languages/:code', async (request, reply) => { }); app.delete('/api/admin/languages/:code', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.languages.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.delete', 'adminWrite'); if (!user) { return; } @@ -946,7 +1288,7 @@ app.get('/api/admin/system-wordings', async (request, reply) => { }); app.put('/api/admin/system-wordings/:key', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.wordings.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.wordings.update', 'adminWrite'); if (!user) { return; } @@ -967,7 +1309,7 @@ app.get('/api/admin/config/:type', async (request, reply) => { }); app.post('/api/admin/config/:type', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.config.create'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'wikiWrite'); if (!user) { return; } @@ -981,7 +1323,7 @@ app.post('/api/admin/config/:type', async (request, reply) => { }); app.put('/api/admin/config/:type/order', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.config.order'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'wikiWrite'); if (!user) { return; } @@ -993,7 +1335,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => { }); app.put('/api/admin/config/:type/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.config.update'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'wikiWrite'); if (!user) { return; } @@ -1006,7 +1348,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => { }); app.delete('/api/admin/config/:type/:id', async (request, reply) => { - const user = await requirePermission(request, reply, 'admin.config.delete'); + const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'wikiWrite'); if (!user) { return; } diff --git a/docker-compose.yml b/docker-compose.yml index 85f4149..f0ba8d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: environment: DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia BACKEND_PORT: 3001 + TRUST_PROXY: ${TRUST_PROXY:-false} FRONTEND_ORIGIN: http://localhost:20015 APP_ORIGIN: http://localhost:20015 UPLOAD_DIR: /app/uploads diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c5674e..23e50e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 '@fastify/static': specifier: ^9.1.3 version: 9.1.3 @@ -297,6 +300,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -1446,6 +1452,12 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2 diff --git a/system-wordings.ts b/system-wordings.ts index 2bf5bcc..ed57067 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -675,7 +675,8 @@ export const systemWordingMessages = { loginRequired: 'Please log in first', verifyEmailFirst: 'Please complete email verification first', permissionDenied: 'Permission denied', - notFound: 'Not found' + notFound: 'Not found', + rateLimited: 'Too many requests. Please try again later.' }, auth: { emailRequired: 'Email is required', @@ -1456,7 +1457,8 @@ export const systemWordingMessages = { loginRequired: '请先登录', verifyEmailFirst: '请先完成邮箱验证', permissionDenied: '权限不足', - notFound: '未找到记录' + notFound: '未找到记录', + rateLimited: '请求过于频繁,请稍后再试。' }, auth: { emailRequired: '请输入邮箱',