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:
33
DESIGN.md
33
DESIGN.md
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '端',
|
||||
|
||||
Reference in New Issue
Block a user