feat(admin): make user rate limits configurable via admin UI

Add rate_limit_settings table and corresponding admin permissions
Replace static user rate limits with dynamic in-memory counters
Add interface in admin panel to configure rate limit policies
This commit is contained in:
2026-05-03 22:11:41 +08:00
parent b0e2464c24
commit deb0b54e71
7 changed files with 491 additions and 153 deletions

View File

@@ -209,10 +209,17 @@
## 滥用防护与限流 ## 滥用防护与限流
- 后端使用 `@fastify/rate-limit` 在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。 - 后端使用 `@fastify/rate-limit` 和应用内用户级计数在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。
- Fastify 默认不信任代理转发 IP部署在可信反向代理后方时可设置 `TRUST_PROXY=true`,让 IP 限流使用代理解析后的客户端 IP。 - Fastify 默认不信任代理转发 IP部署在可信反向代理后方时可设置 `TRUST_PROXY=true`,让 IP 限流使用代理解析后的客户端 IP。
- 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key用户限流使用当前登录用户 ID路由限流使用 HTTP method + route pattern。 - 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key已登录用户限流使用当前登录用户 ID路由限流使用 HTTP method + route pattern。
- 触发限流时 API 返回 429 和本地化通用错误文案,并带 `Retry-After` 与 rate limit headers响应不得返回邮箱、用户 ID、内部 key、token/hash 或调试信息。 - 触发限流时 API 返回 429 和本地化通用错误文案,并带 `Retry-After` 与 rate limit headers响应不得返回邮箱、用户 ID、内部 key、token/hash 或调试信息。
- 可配置的已登录用户限流存储在 `rate_limit_settings`
- `settings`JSON object保存各用户级限流策略的 `maxRequests``timeWindowSeconds``cooldownSeconds`
- `updated_by_user_id`
- `created_at`
- `updated_at`
- 管理端 Access 分组提供 Rate limits 设置区;查看需要 `admin.rate-limits.read`,更新需要 `admin.rate-limits.update`
- 已登录用户级限流策略仅按用户 ID 计数,不再叠加写入路由 IP 限流或用户 + 路由写入限流;认证入口和受保护路由的 IP 防护仍保留。
- 认证入口限流: - 认证入口限流:
- 注册、登录、验证邮箱、请求重置密码、提交重置密码均按 IP + 路由限制为 20 次 / 10 分钟。 - 注册、登录、验证邮箱、请求重置密码、提交重置密码均按 IP + 路由限制为 20 次 / 10 分钟。
- 登录额外按邮箱限制为 5 次 / 15 分钟。 - 登录额外按邮箱限制为 5 次 / 15 分钟。
@@ -220,17 +227,14 @@
- 请求重置密码额外按邮箱限制为 3 次 / 1 小时,并按 IP + 路由限制为 10 次 / 15 分钟。 - 请求重置密码额外按邮箱限制为 3 次 / 1 小时,并按 IP + 路由限制为 10 次 / 15 分钟。
- 提交重置密码额外按 IP + 路由限制为 10 次 / 15 分钟。 - 提交重置密码额外按 IP + 路由限制为 10 次 / 15 分钟。
- 已登录保护路由按 IP + 路由限制为 120 次 / 10 分钟,避免单一来源反复触发鉴权查询。 - 已登录保护路由按 IP + 路由限制为 120 次 / 10 分钟,避免单一来源反复触发鉴权查询。
- 写入路由通用限流: - 用户账号资料写入默认按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。
- 写入路由按 IP + 路由限制为 90 次 / 10 分钟 - 管理写入System config 配置项、用户角色、角色、权限、语言、系统文案、AI 审核设置和限流设置)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间
- 写入路由按用户 ID + 路由限制为 30 次 / 10 分钟 - Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间
- 用户账号资料写入按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。 - 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList、配置项和排序按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 管理写入(用户角色、角色、权限、语言和系统文案)按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 上传按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- Community 写入: - Community 写入:
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。 - Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。 - Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询按 IP + 路由限制为 60 次 / 10 分钟,按用户 ID 限制为 60 次 / 10 分钟,按用户 ID + 路由限制为 30 次 / 10 分钟,并有 1 秒冷却时间。 - Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
## Community 编辑与审计 ## Community 编辑与审计
@@ -749,7 +753,7 @@ API 暴露边界:
- 配置System config。 - 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。 - 内容Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
- 本地化Languages、System wordings。 - 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions。 - 访问权限Users、Roles、Permissions、Rate limits
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。 - 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。 - 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。 - 导航和主要操作使用图标增强识别。
@@ -873,6 +877,9 @@ API 暴露边界:
- `DELETE /api/life-posts/:id/rating` - `DELETE /api/life-posts/:id/rating`
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。 - 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。 - 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 限流设置的查看和更新通过 Access 权限控制:
- `GET /api/admin/rate-limits`:需要 `admin.rate-limits.read`
- `PUT /api/admin/rate-limits`:需要 `admin.rate-limits.update`
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。 - 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。
- 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限。 - 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限。
- `GET /api/admin/system-wordings` - `GET /api/admin/system-wordings`

View File

@@ -177,6 +177,18 @@ CREATE TABLE IF NOT EXISTS ai_moderation_cache (
CHECK (length(model) BETWEEN 1 AND 120) CHECK (length(model) BETWEEN 1 AND 120)
); );
CREATE TABLE IF NOT EXISTS rate_limit_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO rate_limit_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (key, name, description, category, system_permission) INSERT INTO permissions (key, name, description, category, system_permission)
VALUES VALUES
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true), ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
@@ -200,6 +212,8 @@ VALUES
('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true), ('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true),
('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true), ('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true),
('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true), ('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true),
('admin.rate-limits.read', 'View rate limits', 'View user rate limit settings.', 'Rate limits', true),
('admin.rate-limits.update', 'Update rate limits', 'Edit user rate limit settings.', 'Rate limits', true),
('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true), ('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true),
('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true), ('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true),
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true), ('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
@@ -284,6 +298,8 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'admin.wordings.update', 'admin.wordings.update',
'admin.ai-moderation.read', 'admin.ai-moderation.read',
'admin.ai-moderation.update', 'admin.ai-moderation.update',
'admin.rate-limits.read',
'admin.rate-limits.update',
'admin.config.read', 'admin.config.read',
'admin.config.create', 'admin.config.create',
'admin.config.update', 'admin.config.update',
@@ -345,6 +361,16 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'admin' WHERE r.key = 'admin'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'admin.rate-limits.read',
'admin.rate-limits.update'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r

View File

@@ -229,10 +229,27 @@ function serverMessage(
} }
type RateLimitCheck = ReturnType<typeof app.createRateLimit>; type RateLimitCheck = ReturnType<typeof app.createRateLimit>;
type RateLimitResult = Awaited<ReturnType<RateLimitCheck>>;
type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite'; type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
type RateLimitedRequest = FastifyRequest & { type RateLimitPolicySettings = {
rateLimitUserId?: number; maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
};
type RateLimitPolicySettingsMap = Record<RateLimitPolicy, RateLimitPolicySettings>;
type RateLimitFailure = {
max: number;
ttlInSeconds: number;
isBanned?: boolean;
};
type RateLimitSettingsRow = {
settings: unknown;
updatedAt: Date | string;
updatedBy: { id: number; displayName: string } | null;
};
type PublicRateLimitSettings = {
policies: RateLimitPolicySettingsMap;
updatedAt: Date | string | null;
updatedBy: { id: number; displayName: string } | null;
}; };
function hashRateLimitPart(value: string): string { function hashRateLimitPart(value: string): string {
@@ -249,14 +266,6 @@ function emailRateLimitPart(request: FastifyRequest): string {
return hashRateLimitPart(email || 'missing-email'); 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 { function ipRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`; return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`;
} }
@@ -291,139 +300,198 @@ const protectedRouteIpRateLimit = app.createRateLimit({
timeWindow: '10 minutes', timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('protected', request) 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[]> = { const rateLimitPolicyKeys: RateLimitPolicy[] = [
accountWrite: [ 'accountWrite',
writeRouteIpRateLimit, 'adminWrite',
app.createRateLimit({ 'communityReaction',
max: 20, 'communityWrite',
timeWindow: '1 hour', 'fetch',
keyGenerator: (request) => `account-write:user:${userRateLimitPart(request)}` 'upload',
}), 'wikiWrite'
app.createRateLimit({ ];
max: 1, const defaultUserRateLimitSettings: RateLimitPolicySettingsMap = {
timeWindow: '5 seconds', accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
keyGenerator: (request) => `account-write-cooldown:user:${userRateLimitPart(request)}` adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
}), communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
userRouteWriteRateLimit communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
], fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
adminWrite: [ upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
writeRouteIpRateLimit, wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
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
]
}; };
const rateLimitSettingsCacheTtlMs = 30_000;
const minRateLimitWindowSeconds = 60;
const maxRateLimitWindowSeconds = 24 * 60 * 60;
const maxRateLimitRequests = 5_000;
const maxRateLimitCooldownSeconds = 60 * 60;
let rateLimitSettingsCache: { settings: RateLimitPolicySettingsMap; expiresAt: number } | null = null;
let lastRateLimitSweepAt = 0;
const userRateLimitWindows = new Map<string, { count: number; resetAt: number }>();
const userRateLimitCooldowns = new Map<string, number>();
function cloneRateLimitSettings(settings: RateLimitPolicySettingsMap): RateLimitPolicySettingsMap {
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [policy, { ...settings[policy] }])
) as RateLimitPolicySettingsMap;
}
function cleanRateLimitInteger(value: unknown, fallback: number, min: number, max: number): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
return fallback;
}
return numeric;
}
function cleanRateLimitPolicySettings(value: unknown, fallback: RateLimitPolicySettings): RateLimitPolicySettings {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
maxRequests: cleanRateLimitInteger(raw.maxRequests, fallback.maxRequests, 1, maxRateLimitRequests),
timeWindowSeconds: cleanRateLimitInteger(
raw.timeWindowSeconds,
fallback.timeWindowSeconds,
minRateLimitWindowSeconds,
maxRateLimitWindowSeconds
),
cooldownSeconds: cleanRateLimitInteger(raw.cooldownSeconds, fallback.cooldownSeconds, 0, maxRateLimitCooldownSeconds)
};
}
function normalizeRateLimitSettings(value: unknown): RateLimitPolicySettingsMap {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
cleanRateLimitPolicySettings(raw[policy], defaultUserRateLimitSettings[policy])
])
) as RateLimitPolicySettingsMap;
}
function publicRateLimitSettings(row: RateLimitSettingsRow | null, settings: RateLimitPolicySettingsMap): PublicRateLimitSettings {
return {
policies: cloneRateLimitSettings(settings),
updatedAt: row?.updatedAt ?? null,
updatedBy: row?.updatedBy ?? null
};
}
async function rateLimitSettingsRow(): Promise<RateLimitSettingsRow | null> {
const result = await pool.query<RateLimitSettingsRow>(
`
SELECT
s.settings,
s.updated_at AS "updatedAt",
CASE
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy"
FROM rate_limit_settings s
LEFT JOIN users updated_user ON updated_user.id = s.updated_by_user_id
WHERE s.id = true
`
);
return result.rows[0] ?? null;
}
async function runtimeRateLimitSettings(): Promise<RateLimitPolicySettingsMap> {
const now = Date.now();
if (rateLimitSettingsCache && rateLimitSettingsCache.expiresAt > now) {
return rateLimitSettingsCache.settings;
}
const row = await rateLimitSettingsRow();
const settings = normalizeRateLimitSettings(row?.settings);
rateLimitSettingsCache = { settings, expiresAt: now + rateLimitSettingsCacheTtlMs };
return settings;
}
async function getRateLimitSettings(): Promise<PublicRateLimitSettings> {
const row = await rateLimitSettingsRow();
return publicRateLimitSettings(row, normalizeRateLimitSettings(row?.settings));
}
async function updateRateLimitSettings(payload: Record<string, unknown>, userId: number): Promise<PublicRateLimitSettings> {
const policies = payload.policies && typeof payload.policies === 'object' ? payload.policies : payload;
const settings = normalizeRateLimitSettings(policies);
await pool.query(
`
INSERT INTO rate_limit_settings (id, settings, updated_by_user_id, updated_at)
VALUES (true, $1::jsonb, $2, now())
ON CONFLICT (id)
DO UPDATE SET settings = EXCLUDED.settings,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
`,
[JSON.stringify(settings), userId]
);
rateLimitSettingsCache = { settings, expiresAt: Date.now() + rateLimitSettingsCacheTtlMs };
return getRateLimitSettings();
}
function sweepUserRateLimitEntries(now: number): void {
if (now - lastRateLimitSweepAt < 60_000) {
return;
}
lastRateLimitSweepAt = now;
for (const [key, bucket] of userRateLimitWindows.entries()) {
if (bucket.resetAt <= now) {
userRateLimitWindows.delete(key);
}
}
for (const [key, resetAt] of userRateLimitCooldowns.entries()) {
if (resetAt <= now) {
userRateLimitCooldowns.delete(key);
}
}
}
function checkUserPolicyRateLimit(userId: number, policy: RateLimitPolicy, settings: RateLimitPolicySettings): RateLimitFailure | null {
const now = Date.now();
sweepUserRateLimitEntries(now);
const cooldownKey = `${policy}:user:${userId}:cooldown`;
const cooldownResetAt = userRateLimitCooldowns.get(cooldownKey) ?? 0;
if (cooldownResetAt > now) {
return { max: 1, ttlInSeconds: Math.ceil((cooldownResetAt - now) / 1000) };
}
const windowKey = `${policy}:user:${userId}:window`;
const windowResetMs = settings.timeWindowSeconds * 1000;
const existingBucket = userRateLimitWindows.get(windowKey);
const bucket =
existingBucket && existingBucket.resetAt > now
? existingBucket
: { count: 0, resetAt: now + windowResetMs };
if (bucket.count >= settings.maxRequests) {
userRateLimitWindows.set(windowKey, bucket);
return { max: settings.maxRequests, ttlInSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
bucket.count += 1;
userRateLimitWindows.set(windowKey, bucket);
if (settings.cooldownSeconds > 0) {
userRateLimitCooldowns.set(cooldownKey, now + settings.cooldownSeconds * 1000);
}
return null;
}
async function sendRateLimited( async function sendRateLimited(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
result: Extract<RateLimitResult, { isAllowed: false }> result: RateLimitFailure
): Promise<false> { ): Promise<false> {
const retryAfter = Math.max(1, result.ttlInSeconds); const retryAfter = Math.max(1, result.ttlInSeconds);
reply.header('retry-after', retryAfter); reply.header('retry-after', retryAfter);
reply.header('x-ratelimit-limit', result.max); reply.header('x-ratelimit-limit', result.max);
reply.header('x-ratelimit-remaining', 0); reply.header('x-ratelimit-remaining', 0);
reply.header('x-ratelimit-reset', retryAfter); reply.header('x-ratelimit-reset', retryAfter);
reply.code(result.isBanned ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') }); reply.code(result.isBanned === true ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
return false; return false;
} }
@@ -456,8 +524,9 @@ async function enforceUserRateLimits(
user: AuthUser, user: AuthUser,
policy: RateLimitPolicy policy: RateLimitPolicy
): Promise<boolean> { ): Promise<boolean> {
(request as RateLimitedRequest).rateLimitUserId = user.id; const settings = (await runtimeRateLimitSettings())[policy];
return enforceRateLimits(request, reply, userRateLimitPolicies[policy]); const result = checkUserPolicyRateLimit(user.id, policy, settings);
return result ? sendRateLimited(request, reply, result) : true;
} }
function badRequest(message: string): Error & { statusCode: number } { function badRequest(message: string): Error & { statusCode: number } {
@@ -1450,6 +1519,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => {
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id); return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
}); });
app.get('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
return user ? getRateLimitSettings() : undefined;
});
app.put('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.rate-limits.update', 'adminWrite');
if (!user) {
return;
}
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/config/:type', async (request, reply) => { app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read'); const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) { if (!user) {
@@ -1463,7 +1545,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 requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'adminWrite');
if (!user) { if (!user) {
return; return;
} }
@@ -1477,7 +1559,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 requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'adminWrite');
if (!user) { if (!user) {
return; return;
} }
@@ -1489,7 +1571,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 requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) { if (!user) {
return; return;
} }
@@ -1502,7 +1584,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 requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'adminWrite');
if (!user) { if (!user) {
return; return;
} }

View File

@@ -636,6 +636,7 @@ export interface EntityDiscussionCommentPayload {
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions'; export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token'; export type AiModerationAuthMode = 'query-key' | 'bearer-token';
export type RateLimitPolicyKey = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
export interface AiModerationSettings { export interface AiModerationSettings {
enabled: boolean; enabled: boolean;
@@ -660,6 +661,22 @@ export interface AiModerationSettingsPayload {
clearApiKey?: boolean; clearApiKey?: boolean;
} }
export interface RateLimitPolicySettings {
maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
}
export interface RateLimitSettings {
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
updatedAt: string | null;
updatedBy: UserSummary | null;
}
export interface RateLimitSettingsPayload {
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
}
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string { export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
@@ -833,6 +850,9 @@ export const api = {
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'), aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) => updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload), sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload), register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) => verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }), sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),

View File

@@ -806,6 +806,35 @@ button:disabled,
max-width: 680px; max-width: 680px;
} }
.rate-limit-list {
display: grid;
gap: 0;
}
.rate-limit-row {
display: grid;
gap: 10px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.rate-limit-row:last-child {
border-bottom: 0;
}
.rate-limit-row h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
font-weight: 900;
}
.rate-limit-fields {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 12px;
}
.pokemon-edit-form { .pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px); height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0; min-height: 0;
@@ -6002,7 +6031,8 @@ button:disabled,
.life-toolbar, .life-toolbar,
.life-toolbar__search, .life-toolbar__search,
.life-toolbar__filters { .life-toolbar__filters,
.rate-limit-fields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -46,6 +46,10 @@ import {
type Permission, type Permission,
type PermissionPayload, type PermissionPayload,
type Pokemon, type Pokemon,
type RateLimitPolicyKey,
type RateLimitPolicySettings,
type RateLimitSettings,
type RateLimitSettingsPayload,
type Recipe, type Recipe,
type RoleDetail, type RoleDetail,
type RolePayload, type RolePayload,
@@ -59,6 +63,7 @@ type AdminTab =
| 'users' | 'users'
| 'roles' | 'roles'
| 'permissions' | 'permissions'
| 'rateLimits'
| 'aiModeration' | 'aiModeration'
| 'config' | 'config'
| 'languages' | 'languages'
@@ -77,11 +82,36 @@ type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
isRateable?: boolean; isRateable?: boolean;
changeLog?: string; changeLog?: string;
}; };
type RateLimitPolicyForm = {
maxRequests: number;
timeWindowMinutes: number;
cooldownSeconds: number;
};
const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'accountWrite',
'adminWrite',
'wikiWrite',
'communityWrite',
'communityReaction',
'upload',
'fetch'
];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const adminTabIcons: Record<AdminTab, AppIcon> = { const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile, users: iconProfile,
roles: iconKey, roles: iconKey,
permissions: iconKey, permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin, aiModeration: iconAdmin,
config: iconAdmin, config: iconAdmin,
languages: iconTranslate, languages: iconTranslate,
@@ -128,7 +158,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
items: [ items: [
{ key: 'users', label: t('pages.admin.users'), permission: 'admin.users.read' }, { key: 'users', label: t('pages.admin.users'), permission: 'admin.users.read' },
{ key: 'roles', label: t('pages.admin.roles'), permission: 'admin.roles.read' }, { key: 'roles', label: t('pages.admin.roles'), permission: 'admin.roles.read' },
{ key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' } { key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' },
{ key: 'rateLimits', label: t('pages.admin.rateLimits'), permission: 'admin.rate-limits.read' }
] ]
} }
]; ];
@@ -168,6 +199,7 @@ const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]); const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null); const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const busy = ref(false); const busy = ref(false);
const contentLoading = ref(false); const contentLoading = ref(false);
@@ -194,6 +226,18 @@ const aiModerationForm = ref({
apiKey: '', apiKey: '',
clearApiKey: false clearApiKey: false
}); });
const rateLimitForm = ref<Record<RateLimitPolicyKey, RateLimitPolicyForm>>(
Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: defaultRateLimitPolicies[policy].maxRequests,
timeWindowMinutes: defaultRateLimitPolicies[policy].timeWindowSeconds / 60,
cooldownSeconds: defaultRateLimitPolicies[policy].cooldownSeconds
}
])
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>
);
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] }); const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true }); const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] }); const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
@@ -299,6 +343,15 @@ const aiModerationAuthModeOptions = computed<Array<{ value: AiModerationAuthMode
{ value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') }, { value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') },
{ value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') } { value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') }
]); ]);
const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label: string }>>(() => [
{ value: 'accountWrite', label: t('pages.admin.rateLimitAccountWrite') },
{ value: 'adminWrite', label: t('pages.admin.rateLimitAdminWrite') },
{ value: 'wikiWrite', label: t('pages.admin.rateLimitWikiWrite') },
{ value: 'communityWrite', label: t('pages.admin.rateLimitCommunityWrite') },
{ value: 'communityReaction', label: t('pages.admin.rateLimitCommunityReaction') },
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
]);
const filteredWordingRows = computed(() => const filteredWordingRows = computed(() =>
wordingRows.value.filter((item) => { wordingRows.value.filter((item) => {
if (wordingModule.value && item.module !== wordingModule.value) return false; if (wordingModule.value && item.module !== wordingModule.value) return false;
@@ -422,6 +475,22 @@ function resetAiModerationForm(settings: AiModerationSettings | null = aiModerat
}; };
} }
function resetRateLimitForm(settings: RateLimitSettings | null = rateLimitSettings.value) {
rateLimitForm.value = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => {
const policySettings = settings?.policies[policy] ?? defaultRateLimitPolicies[policy];
return [
policy,
{
maxRequests: policySettings.maxRequests,
timeWindowMinutes: Math.max(1, Math.round(policySettings.timeWindowSeconds / 60)),
cooldownSeconds: policySettings.cooldownSeconds
}
];
})
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>;
}
function resetUserRoleForm() { function resetUserRoleForm() {
userRoleForm.value = { userId: 0, roleIds: [] }; userRoleForm.value = { userId: 0, roleIds: [] };
} }
@@ -854,6 +923,11 @@ async function loadAiModerationSettings() {
resetAiModerationForm(aiModerationSettings.value); resetAiModerationForm(aiModerationSettings.value);
} }
async function loadRateLimitSettings() {
rateLimitSettings.value = await api.rateLimitSettings();
resetRateLimitForm(rateLimitSettings.value);
}
async function reloadWordings() { async function reloadWordings() {
await run(loadWordings); await run(loadWordings);
} }
@@ -893,6 +967,24 @@ async function saveAiModerationSettings() {
}); });
} }
async function saveRateLimitSettings() {
await run(async () => {
const policies = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: rateLimitForm.value[policy].maxRequests,
timeWindowSeconds: rateLimitForm.value[policy].timeWindowMinutes * 60,
cooldownSeconds: rateLimitForm.value[policy].cooldownSeconds
}
])
) as RateLimitSettingsPayload['policies'];
rateLimitSettings.value = await api.updateRateLimitSettings({ policies });
resetRateLimitForm(rateLimitSettings.value);
});
}
async function saveUserRoles() { async function saveUserRoles() {
await run(async () => { await run(async () => {
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds); userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
@@ -955,6 +1047,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'users') await loadUsers(); if (activeTab.value === 'users') await loadUsers();
if (activeTab.value === 'roles') await loadRoles(); if (activeTab.value === 'roles') await loadRoles();
if (activeTab.value === 'permissions') await loadPermissions(); if (activeTab.value === 'permissions') await loadPermissions();
if (activeTab.value === 'rateLimits') await loadRateLimitSettings();
if (activeTab.value === 'languages') await loadLanguages(); if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings(); if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings(); if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
@@ -1235,6 +1328,64 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'rateLimits'" class="detail-section">
<form class="modal-edit-form" @submit.prevent="saveRateLimitSettings">
<div class="detail-section__header">
<h2>{{ t('pages.admin.rateLimits') }}</h2>
<button v-if="can('admin.rate-limits.update')" class="ui-button ui-button--primary ui-button--small" type="submit" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</div>
<div class="rate-limit-list">
<div v-for="policy in rateLimitPolicyOptions" :key="policy.value" class="rate-limit-row">
<h3>{{ policy.label }}</h3>
<div class="rate-limit-fields">
<div class="field">
<label :for="`rate-limit-${policy.value}-max`">{{ t('pages.admin.rateLimitMaxRequests') }}</label>
<input
:id="`rate-limit-${policy.value}-max`"
v-model.number="rateLimitForm[policy.value].maxRequests"
type="number"
min="1"
max="5000"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-window`">{{ t('pages.admin.rateLimitWindowMinutes') }}</label>
<input
:id="`rate-limit-${policy.value}-window`"
v-model.number="rateLimitForm[policy.value].timeWindowMinutes"
type="number"
min="1"
max="1440"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-cooldown`">{{ t('pages.admin.rateLimitCooldownSeconds') }}</label>
<input
:id="`rate-limit-${policy.value}-cooldown`"
v-model.number="rateLimitForm[policy.value].cooldownSeconds"
type="number"
min="0"
max="3600"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
</div>
</div>
</div>
</form>
</section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<div class="detail-section__header"> <div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2> <h2>{{ t('pages.admin.checklist') }}</h2>

View File

@@ -838,6 +838,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key missing', aiModerationApiKeyMissing: 'API Key missing',
aiModerationClearApiKey: 'Clear saved API Key', aiModerationClearApiKey: 'Clear saved API Key',
aiModerationSettings: 'AI moderation settings', aiModerationSettings: 'AI moderation settings',
rateLimits: 'Rate limits',
rateLimitMaxRequests: 'Max requests',
rateLimitWindowMinutes: 'Window minutes',
rateLimitCooldownSeconds: 'Cooldown seconds',
rateLimitAccountWrite: 'Account writes',
rateLimitAdminWrite: 'Management writes',
rateLimitWikiWrite: 'Wiki content writes',
rateLimitCommunityWrite: 'Community writes',
rateLimitCommunityReaction: 'Community reactions',
rateLimitUpload: 'Uploads',
rateLimitFetch: 'Pokemon fetch',
wordingLocale: 'Locale', wordingLocale: 'Locale',
wordingModule: 'Module', wordingModule: 'Module',
wordingSurface: 'Surface', wordingSurface: 'Surface',
@@ -1913,6 +1924,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key 未配置', aiModerationApiKeyMissing: 'API Key 未配置',
aiModerationClearApiKey: '清除已保存 API Key', aiModerationClearApiKey: '清除已保存 API Key',
aiModerationSettings: 'AI 审核设置', aiModerationSettings: 'AI 审核设置',
rateLimits: '限流',
rateLimitMaxRequests: '最大请求数',
rateLimitWindowMinutes: '窗口分钟数',
rateLimitCooldownSeconds: '冷却秒数',
rateLimitAccountWrite: '账号写入',
rateLimitAdminWrite: '管理写入',
rateLimitWikiWrite: 'Wiki 内容写入',
rateLimitCommunityWrite: '社区写入',
rateLimitCommunityReaction: '社区 Reaction',
rateLimitUpload: '上传',
rateLimitFetch: 'Pokemon Fetch',
wordingLocale: '语言', wordingLocale: '语言',
wordingModule: '模块', wordingModule: '模块',
wordingSurface: '端', wordingSurface: '端',