feat(api): implement rate limiting for abuse prevention

Add @fastify/rate-limit with granular policies for different routes
Support TRUST_PROXY environment variable for reverse proxies
This commit is contained in:
2026-05-03 15:04:07 +08:00
parent 8f55db9061
commit 0c76d6bfc8
7 changed files with 453 additions and 69 deletions

View File

@@ -3,6 +3,7 @@ POSTGRES_USER=pokopia
POSTGRES_PASSWORD=pokopia POSTGRES_PASSWORD=pokopia
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
BACKEND_PORT=3001 BACKEND_PORT=3001
TRUST_PROXY=false
FRONTEND_ORIGIN=http://localhost:20015 FRONTEND_ORIGIN=http://localhost:20015
APP_ORIGIN=http://localhost:20015 APP_ORIGIN=http://localhost:20015
VITE_API_BASE_URL=http://localhost:3001 VITE_API_BASE_URL=http://localhost:3001

View File

@@ -202,6 +202,31 @@
- 当前版本不提供积分奖励、排行榜、邀请邮件发送、邀请制注册限制、后台统计或公开邀请人资料页。 - 当前版本不提供积分奖励、排行榜、邀请邮件发送、邀请制注册限制、后台统计或公开邀请人资料页。
- Referral API 对外只返回当前用户自己的 Referral 摘要不返回被邀请用户邮箱、token/hash、内部审计字段或被邀请用户明细。 - 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 编辑与审计 ## Community 编辑与审计
- 已验证且拥有对应权限的用户可以通过前台或管理入口编辑 Wiki 内容。 - 已验证且拥有对应权限的用户可以通过前台或管理入口编辑 Wiki 内容。

View File

@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@fastify/cors": "latest", "@fastify/cors": "latest",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"fastify": "latest", "fastify": "latest",
"pg": "latest" "pg": "latest"

View File

@@ -1,8 +1,10 @@
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import multipart, { type MultipartFile } from '@fastify/multipart'; import multipart, { type MultipartFile } from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import Fastify from 'fastify'; import Fastify from 'fastify';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { createHash } from 'node:crypto';
import { mkdir } from 'node:fs/promises'; import { mkdir } from 'node:fs/promises';
import { import {
changeCurrentUserPassword, changeCurrentUserPassword,
@@ -111,7 +113,8 @@ import {
} from './uploads.ts'; } from './uploads.ts';
const app = Fastify({ const app = Fastify({
logger: true logger: true,
trustProxy: process.env.TRUST_PROXY === 'true'
}); });
await app.register(cors, { await app.register(cors, {
@@ -120,6 +123,11 @@ await app.register(cors, {
origin: process.env.FRONTEND_ORIGIN ?? true origin: process.env.FRONTEND_ORIGIN ?? true
}); });
await app.register(rateLimit, {
global: false,
hook: 'preHandler'
});
await mkdir(uploadRoot, { recursive: true }); await mkdir(uploadRoot, { recursive: true });
await app.register(multipart, { await app.register(multipart, {
limits: { limits: {
@@ -149,6 +157,10 @@ app.setErrorHandler(async (error, _request, reply) => {
return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') }); 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) { if (pgError.statusCode && pgError.statusCode < 500) {
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) }); return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
} }
@@ -182,10 +194,243 @@ function serverMessage(
| 'verifyEmailFirst' | 'verifyEmailFirst'
| 'permissionDenied' | 'permissionDenied'
| 'notFound' | 'notFound'
| 'rateLimited'
): Promise<string> { ): Promise<string> {
return systemMessage(locale, `server.errors.${key}`); return systemMessage(locale, `server.errors.${key}`);
} }
type RateLimitCheck = ReturnType<typeof app.createRateLimit>;
type RateLimitResult = Awaited<ReturnType<RateLimitCheck>>;
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<string, unknown>) : {};
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<RateLimitPolicy, RateLimitCheck[]> = {
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<RateLimitResult, { isAllowed: false }>
): Promise<false> {
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<boolean> {
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<boolean> {
return enforceRateLimits(request, reply, [authRouteIpRateLimit, ...checks]);
}
async function enforceUserRateLimits(
request: FastifyRequest,
reply: FastifyReply,
user: AuthUser,
policy: RateLimitPolicy
): Promise<boolean> {
(request as RateLimitedRequest).rateLimitUserId = user.id;
return enforceRateLimits(request, reply, userRateLimitPolicies[policy]);
}
function badRequest(message: string): Error & { statusCode: number } { function badRequest(message: string): Error & { statusCode: number } {
const error = new Error(message) as Error & { statusCode: number }; const error = new Error(message) as Error & { statusCode: number };
error.statusCode = 400; error.statusCode = 400;
@@ -197,6 +442,10 @@ async function notFound(reply: FastifyReply, request: FastifyRequest) {
} }
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> { async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return null;
}
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request); const locale = requestLocale(request);
@@ -250,6 +499,34 @@ async function requireAnyPermission(
return user; return user;
} }
async function requirePermissionWithRateLimits(
request: FastifyRequest,
reply: FastifyReply,
permissionKey: string,
policy: RateLimitPolicy
): Promise<AuthUser | null> {
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<AuthUser | null> {
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<AuthUser | null> { async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
if (!token) { if (!token) {
@@ -263,23 +540,51 @@ async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
} }
} }
app.post('/api/auth/register', async (request, reply) => app.post('/api/auth/register', async (request, reply) => {
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request))) if (!(await enforceAuthRateLimits(request, reply, [registerEmailRateLimit]))) {
); return;
}
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>, requestLocale(request))); return reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)));
});
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, 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) => return verifyEmail(request.body as Record<string, unknown>, requestLocale(request));
requestPasswordReset(request.body as Record<string, unknown>, requestLocale(request)) });
);
app.post('/api/auth/reset-password', async (request) => app.post('/api/auth/login', async (request, reply) => {
resetPassword(request.body as Record<string, unknown>, requestLocale(request)) if (!(await enforceAuthRateLimits(request, reply, [loginEmailRateLimit]))) {
); return;
}
return loginUser(request.body as Record<string, unknown>, 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<string, unknown>, requestLocale(request));
});
app.post('/api/auth/reset-password', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, [passwordResetRouteIpRateLimit]))) {
return;
}
return resetPassword(request.body as Record<string, unknown>, requestLocale(request));
});
app.get('/api/auth/me', async (request, reply) => { app.get('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; 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) => { app.patch('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; 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') }); 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<string, unknown>) : {}; const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) }; return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
}); });
app.patch('/api/auth/me/password', async (request, reply) => { app.patch('/api/auth/me/password', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; 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') }); 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<string, unknown>) : {}; const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return changeCurrentUserPassword(user.id, payload, token, requestLocale(request)); return changeCurrentUserPassword(user.id, payload, token, requestLocale(request));
}); });
app.get('/api/auth/referral', async (request, reply) => { app.get('/api/auth/referral', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; 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) => { 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) { if (!user) {
return; return;
} }
@@ -354,12 +679,12 @@ app.get('/api/admin/roles', async (request, reply) => {
}); });
app.post('/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<string, unknown>)) : undefined; return user ? reply.code(201).send(await createRole(request.body as Record<string, unknown>)) : undefined;
}); });
app.put('/api/admin/roles/:id', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -368,7 +693,7 @@ app.put('/api/admin/roles/:id', async (request, reply) => {
}); });
app.put('/api/admin/roles/:id/permissions', 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) { if (!user) {
return; return;
} }
@@ -377,7 +702,7 @@ app.put('/api/admin/roles/:id/permissions', async (request, reply) => {
}); });
app.delete('/api/admin/roles/:id', 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) { if (!user) {
return; return;
} }
@@ -392,12 +717,12 @@ app.get('/api/admin/permissions', async (request, reply) => {
}); });
app.post('/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<string, unknown>)) : undefined; return user ? reply.code(201).send(await createPermission(request.body as Record<string, unknown>)) : undefined;
}); });
app.put('/api/admin/permissions/:id', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -406,7 +731,7 @@ app.put('/api/admin/permissions/:id', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -469,14 +794,14 @@ app.get('/api/life-posts', async (request) => {
}); });
app.post('/api/life-posts', async (request, reply) => { 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 return user
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request))) ? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined; : undefined;
}); });
app.post('/api/life-posts/:postId/comments', async (request, reply) => { 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; return;
} }
@@ -517,7 +847,7 @@ app.put('/api/life-posts/:id', async (request, reply) => {
}); });
app.put('/api/life-posts/:id/reaction', 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; return;
} }
@@ -537,7 +867,12 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
}); });
app.delete('/api/life-posts/:id', 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) { if (!user) {
return; return;
} }
@@ -547,7 +882,12 @@ app.delete('/api/life-posts/:id', async (request, reply) => {
}); });
app.delete('/api/life-comments/: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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; return;
} }
@@ -600,10 +940,12 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a
}); });
app.delete('/api/discussions/comments/:id', async (request, reply) => { app.delete('/api/discussions/comments/:id', async (request, reply) => {
const user = await requireAnyPermission(request, reply, [ const user = await requireAnyPermissionWithRateLimits(
'discussions.comments.delete', request,
'discussions.comments.delete-any' reply,
]); ['discussions.comments.delete', 'discussions.comments.delete-any'],
'communityWrite'
);
if (!user) { if (!user) {
return; return;
} }
@@ -622,7 +964,7 @@ app.get('/api/pokemon', async (request) =>
); );
app.get('/api/pokemon/fetch-options', async (request, reply) => { 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 return user
? listPokemonFetchOptions(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) ? listPokemonFetchOptions(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
: undefined; : undefined;
@@ -640,19 +982,19 @@ app.get('/api/pokemon/:id', async (request, reply) => {
}); });
app.post('/api/pokemon', 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 return user
? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request))) ? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined; : undefined;
}); });
app.post('/api/pokemon/fetch', async (request, reply) => { 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<string, unknown>, user.id) : undefined; return user ? fetchPokemonData(request.body as Record<string, unknown>, user.id) : undefined;
}); });
app.post('/api/pokemon/image-options', async (request, reply) => { 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<string, unknown>) : undefined; return user ? fetchPokemonImageOptions(request.body as Record<string, unknown>) : undefined;
}); });
@@ -664,7 +1006,7 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
const permissionKey = const permissionKey =
entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload'; 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) { if (!user) {
return; return;
} }
@@ -684,7 +1026,7 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
}); });
app.put('/api/pokemon/:id', 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) { if (!user) {
return; return;
} }
@@ -699,7 +1041,7 @@ app.put('/api/pokemon/:id', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -722,14 +1064,14 @@ app.get('/api/habitats/:id', async (request, reply) => {
}); });
app.post('/api/habitats', 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 return user
? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request))) ? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined; : undefined;
}); });
app.put('/api/habitats/:id', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -744,7 +1086,7 @@ app.put('/api/habitats/:id', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -769,14 +1111,14 @@ app.get('/api/items/:id', async (request, reply) => {
}); });
app.post('/api/items', 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 return user
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request))) ? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined; : undefined;
}); });
app.put('/api/items/:id', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -791,7 +1133,7 @@ app.put('/api/items/:id', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -816,14 +1158,14 @@ app.get('/api/recipes/:id', async (request, reply) => {
}); });
app.post('/api/recipes', 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 return user
? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request))) ? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined; : undefined;
}); });
app.put('/api/recipes/:id', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -838,7 +1180,7 @@ app.put('/api/recipes/:id', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -848,7 +1190,7 @@ app.delete('/api/recipes/:id', async (request, reply) => {
}); });
app.post('/api/admin/daily-checklist', 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 return user
? reply ? reply
.code(201) .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) => { 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<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
}); });
app.put('/api/admin/daily-checklist/:id', async (request, reply) => { 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; return;
} }
@@ -887,22 +1229,22 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
}); });
app.put('/api/admin/pokemon/order', 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<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
}); });
app.put('/api/admin/items/order', async (request, reply) => { 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<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
}); });
app.put('/api/admin/recipes/order', async (request, reply) => { 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<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
}); });
app.put('/api/admin/habitats/order', async (request, reply) => { 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<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderHabitats(request.body as Record<string, unknown>, 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) => { 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<string, unknown>)) : undefined; return user ? reply.code(201).send(await createLanguage(request.body as Record<string, unknown>)) : undefined;
}); });
app.put('/api/admin/languages/order', async (request, reply) => { 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<string, unknown>) : undefined; return user ? reorderLanguages(request.body as Record<string, unknown>) : undefined;
}); });
app.put('/api/admin/languages/:code', async (request, reply) => { 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) { if (!user) {
return; return;
} }
@@ -931,7 +1273,7 @@ app.put('/api/admin/languages/:code', async (request, reply) => {
}); });
app.delete('/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) { if (!user) {
return; return;
} }
@@ -946,7 +1288,7 @@ app.get('/api/admin/system-wordings', async (request, reply) => {
}); });
app.put('/api/admin/system-wordings/:key', 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) { if (!user) {
return; return;
} }
@@ -967,7 +1309,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
}); });
app.post('/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) { if (!user) {
return; return;
} }
@@ -981,7 +1323,7 @@ app.post('/api/admin/config/:type', async (request, reply) => {
}); });
app.put('/api/admin/config/:type/order', 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; 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) => { 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) { if (!user) {
return; return;
} }

View File

@@ -22,6 +22,7 @@ services:
environment: environment:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001 BACKEND_PORT: 3001
TRUST_PROXY: ${TRUST_PROXY:-false}
FRONTEND_ORIGIN: http://localhost:20015 FRONTEND_ORIGIN: http://localhost:20015
APP_ORIGIN: http://localhost:20015 APP_ORIGIN: http://localhost:20015
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads

12
pnpm-lock.yaml generated
View File

@@ -16,6 +16,9 @@ importers:
'@fastify/multipart': '@fastify/multipart':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
'@fastify/rate-limit':
specifier: ^10.3.0
version: 10.3.0
'@fastify/static': '@fastify/static':
specifier: ^9.1.3 specifier: ^9.1.3
version: 9.1.3 version: 9.1.3
@@ -297,6 +300,9 @@ packages:
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} 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': '@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
@@ -1446,6 +1452,12 @@ snapshots:
'@fastify/forwarded': 3.0.1 '@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0 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': '@fastify/send@4.1.0':
dependencies: dependencies:
'@lukeed/ms': 2.0.2 '@lukeed/ms': 2.0.2

View File

@@ -675,7 +675,8 @@ export const systemWordingMessages = {
loginRequired: 'Please log in first', loginRequired: 'Please log in first',
verifyEmailFirst: 'Please complete email verification first', verifyEmailFirst: 'Please complete email verification first',
permissionDenied: 'Permission denied', permissionDenied: 'Permission denied',
notFound: 'Not found' notFound: 'Not found',
rateLimited: 'Too many requests. Please try again later.'
}, },
auth: { auth: {
emailRequired: 'Email is required', emailRequired: 'Email is required',
@@ -1456,7 +1457,8 @@ export const systemWordingMessages = {
loginRequired: '请先登录', loginRequired: '请先登录',
verifyEmailFirst: '请先完成邮箱验证', verifyEmailFirst: '请先完成邮箱验证',
permissionDenied: '权限不足', permissionDenied: '权限不足',
notFound: '未找到记录' notFound: '未找到记录',
rateLimited: '请求过于频繁,请稍后再试。'
}, },
auth: { auth: {
emailRequired: '请输入邮箱', emailRequired: '请输入邮箱',