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