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。
- 限流 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 或调试信息。
- 可配置的已登录用户限流存储在 `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 分钟。
- 登录额外按邮箱限制为 5 次 / 15 分钟。
@@ -220,17 +227,14 @@
- 请求重置密码额外按邮箱限制为 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 秒冷却时间。
- 用户账号资料写入默认按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。
- 管理写入System config 配置项、用户角色、角色、权限、语言、系统文案、AI 审核设置和限流设置)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 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 秒冷却时间。
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
## Community 编辑与审计
@@ -749,7 +753,7 @@ API 暴露边界:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions。
- 访问权限Users、Roles、Permissions、Rate limits
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。
@@ -873,6 +877,9 @@ API 暴露边界:
- `DELETE /api/life-posts/:id/rating`
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 限流设置的查看和更新通过 Access 权限控制:
- `GET /api/admin/rate-limits`:需要 `admin.rate-limits.read`
- `PUT /api/admin/rate-limits`:需要 `admin.rate-limits.update`
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。
- 系统级文案的查看和更新需要对应 `admin.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)
);
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)
VALUES
('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.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.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.create', 'Create system config', 'Create 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.ai-moderation.read',
'admin.ai-moderation.update',
'admin.rate-limits.read',
'admin.rate-limits.update',
'admin.config.read',
'admin.config.create',
'admin.config.update',
@@ -345,6 +361,16 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'admin'
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)
SELECT r.id, p.id
FROM roles r

View File

@@ -229,10 +229,27 @@ function serverMessage(
}
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;
type RateLimitPolicySettings = {
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 {
@@ -249,14 +266,6 @@ function emailRateLimitPart(request: FastifyRequest): string {
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)}`;
}
@@ -291,139 +300,198 @@ const protectedRouteIpRateLimit = app.createRateLimit({
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
]
const rateLimitPolicyKeys: RateLimitPolicy[] = [
'accountWrite',
'adminWrite',
'communityReaction',
'communityWrite',
'fetch',
'upload',
'wikiWrite'
];
const defaultUserRateLimitSettings: RateLimitPolicySettingsMap = {
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 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(
request: FastifyRequest,
reply: FastifyReply,
result: Extract<RateLimitResult, { isAllowed: false }>
result: RateLimitFailure
): 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') });
reply.code(result.isBanned === true ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
return false;
}
@@ -456,8 +524,9 @@ async function enforceUserRateLimits(
user: AuthUser,
policy: RateLimitPolicy
): Promise<boolean> {
(request as RateLimitedRequest).rateLimitUserId = user.id;
return enforceRateLimits(request, reply, userRateLimitPolicies[policy]);
const settings = (await runtimeRateLimitSettings())[policy];
const result = checkUserPolicyRateLimit(user.id, policy, settings);
return result ? sendRateLimited(request, reply, result) : true;
}
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);
});
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) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {
@@ -1463,7 +1545,7 @@ app.get('/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) {
return;
}
@@ -1477,7 +1559,7 @@ app.post('/api/admin/config/:type', 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) {
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) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) {
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) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'adminWrite');
if (!user) {
return;
}

View File

@@ -636,6 +636,7 @@ export interface EntityDiscussionCommentPayload {
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
export type RateLimitPolicyKey = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
export interface AiModerationSettings {
enabled: boolean;
@@ -660,6 +661,22 @@ export interface AiModerationSettingsPayload {
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 {
const search = new URLSearchParams();
@@ -833,6 +850,9 @@ export const api = {
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
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),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),

View File

@@ -806,6 +806,35 @@ button:disabled,
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 {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;
@@ -6002,7 +6031,8 @@ button:disabled,
.life-toolbar,
.life-toolbar__search,
.life-toolbar__filters {
.life-toolbar__filters,
.rate-limit-fields {
grid-template-columns: 1fr;
}

View File

@@ -46,6 +46,10 @@ import {
type Permission,
type PermissionPayload,
type Pokemon,
type RateLimitPolicyKey,
type RateLimitPolicySettings,
type RateLimitSettings,
type RateLimitSettingsPayload,
type Recipe,
type RoleDetail,
type RolePayload,
@@ -59,6 +63,7 @@ type AdminTab =
| 'users'
| 'roles'
| 'permissions'
| 'rateLimits'
| 'aiModeration'
| 'config'
| 'languages'
@@ -77,11 +82,36 @@ type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
isRateable?: boolean;
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> = {
users: iconProfile,
roles: iconKey,
permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin,
config: iconAdmin,
languages: iconTranslate,
@@ -128,7 +158,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
items: [
{ key: 'users', label: t('pages.admin.users'), permission: 'admin.users.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 wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
@@ -194,6 +226,18 @@ const aiModerationForm = ref({
apiKey: '',
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 roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
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: '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(() =>
wordingRows.value.filter((item) => {
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() {
userRoleForm.value = { userId: 0, roleIds: [] };
}
@@ -854,6 +923,11 @@ async function loadAiModerationSettings() {
resetAiModerationForm(aiModerationSettings.value);
}
async function loadRateLimitSettings() {
rateLimitSettings.value = await api.rateLimitSettings();
resetRateLimitForm(rateLimitSettings.value);
}
async function reloadWordings() {
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() {
await run(async () => {
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 === 'roles') await loadRoles();
if (activeTab.value === 'permissions') await loadPermissions();
if (activeTab.value === 'rateLimits') await loadRateLimitSettings();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
@@ -1235,6 +1328,64 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</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">
<div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2>

View File

@@ -838,6 +838,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key missing',
aiModerationClearApiKey: 'Clear saved API Key',
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',
wordingModule: 'Module',
wordingSurface: 'Surface',
@@ -1913,6 +1924,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key 未配置',
aiModerationClearApiKey: '清除已保存 API Key',
aiModerationSettings: 'AI 审核设置',
rateLimits: '限流',
rateLimitMaxRequests: '最大请求数',
rateLimitWindowMinutes: '窗口分钟数',
rateLimitCooldownSeconds: '冷却秒数',
rateLimitAccountWrite: '账号写入',
rateLimitAdminWrite: '管理写入',
rateLimitWikiWrite: 'Wiki 内容写入',
rateLimitCommunityWrite: '社区写入',
rateLimitCommunityReaction: '社区 Reaction',
rateLimitUpload: '上传',
rateLimitFetch: 'Pokemon Fetch',
wordingLocale: '语言',
wordingModule: '模块',
wordingSurface: '端',