Compare commits
3 Commits
b0e2464c24
...
a0e07f101a
| Author | SHA1 | Date | |
|---|---|---|---|
| a0e07f101a | |||
| df212a4e27 | |||
| deb0b54e71 |
43
DESIGN.md
43
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 编辑与审计
|
||||||
|
|
||||||
@@ -433,19 +437,21 @@ Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,
|
|||||||
Pokemon 编辑表单使用标签页组织字段:
|
Pokemon 编辑表单使用标签页组织字段:
|
||||||
|
|
||||||
- 编辑表单提供 Fetch data 功能:
|
- 编辑表单提供 Fetch data 功能:
|
||||||
- 已验证且拥有 `pokemon.fetch` 权限的用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。
|
- 已验证且拥有 `pokemon.fetch` 权限的用户可在 Fetch 输入框输入 data identifier 或官方 data Pokemon ID,从同一个搜索输入查询基础资料或图片候选。
|
||||||
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
||||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||||
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
||||||
- Fetch 只填入 CSV 可提供的字段:ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
- Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||||
|
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID,只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
|
||||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||||
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
||||||
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||||
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
|
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
|
||||||
- 编辑表单提供 Pokemon 图片选择功能:
|
- 编辑表单提供 Pokemon 图片选择功能:
|
||||||
- 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
|
- 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / 官方 data Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
|
||||||
- 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。
|
- 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。
|
||||||
|
- 静态图片与官方 data identifier / 官方 data Pokemon ID 关联,不与 Pokopia 可编辑展示 ID 关联;用户修改 Pokopia 展示 ID 后,已选静态图片仍可保存。
|
||||||
- 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
- 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||||
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
|
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
|
||||||
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
||||||
@@ -749,7 +755,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,避免在内容页继续增加侧边栏。
|
||||||
- 导航和主要操作使用图标增强识别。
|
- 导航和主要操作使用图标增强识别。
|
||||||
@@ -846,7 +852,7 @@ API 暴露边界:
|
|||||||
受权限保护的编辑 API:
|
受权限保护的编辑 API:
|
||||||
|
|
||||||
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create`、`update`、`delete` 权限。
|
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create`、`update`、`delete` 权限。
|
||||||
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要 `pokemon.fetch`;只返回 `id`、`identifier`、`name`。
|
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;支持 `all=true` 返回完整候选列表供前端本地筛选;需要 `pokemon.fetch`;只返回 `id`、`identifier`、`name`。
|
||||||
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。
|
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。
|
||||||
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id`、`identifier` 和图片候选列表。
|
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id`、`identifier` 和图片候选列表。
|
||||||
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon`、`items`、`habitats`;返回图片历史记录项和可展示 URL。
|
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon`、`items`、`habitats`;返回图片历史记录项和可展示 URL。
|
||||||
@@ -873,6 +879,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
|
||||||
|
|||||||
@@ -1520,13 +1520,28 @@ function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
|||||||
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
|
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null {
|
function pokemonImageDataIdFromPath(path: string): number | null {
|
||||||
|
const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(match[1]);
|
||||||
|
return Number.isSafeInteger(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageCandidateForPath(path: string): PokemonImage | null {
|
||||||
const cleanPath = path.trim();
|
const cleanPath = path.trim();
|
||||||
|
const id = pokemonImageDataIdFromPath(cleanPath);
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
|
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
|
||||||
return candidate ? pokemonImageWithUrl(candidate) : null;
|
return candidate ? pokemonImageWithUrl(candidate) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | null {
|
function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
|
||||||
const path = typeof value === 'string' ? value.trim() : '';
|
const path = typeof value === 'string' ? value.trim() : '';
|
||||||
if (path === '') {
|
if (path === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -1541,13 +1556,13 @@ function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | nu
|
|||||||
url: uploadImageUrl(path),
|
url: uploadImageUrl(path),
|
||||||
style: 'Upload',
|
style: 'Upload',
|
||||||
version: 'Community upload',
|
version: 'Community upload',
|
||||||
variant: `#${pokemonId}`,
|
variant: `#${displayId}`,
|
||||||
description: '',
|
description: '',
|
||||||
source: 'upload'
|
source: 'upload'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = pokemonImageCandidateForPath(pokemonId, path);
|
const image = pokemonImageCandidateForPath(path);
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw validationError('server.validation.pokemonImagePathInvalid');
|
throw validationError('server.validation.pokemonImagePathInvalid');
|
||||||
}
|
}
|
||||||
@@ -1789,12 +1804,13 @@ function pokemonFetchOptionMatches(
|
|||||||
|
|
||||||
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
|
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
|
||||||
const search = asString(paramsQuery.search)?.trim() ?? '';
|
const search = asString(paramsQuery.search)?.trim() ?? '';
|
||||||
|
const includeAll = asString(paramsQuery.all) === 'true';
|
||||||
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
||||||
|
const rows = data.pokemonRows.filter(
|
||||||
|
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
|
||||||
|
);
|
||||||
|
|
||||||
return data.pokemonRows
|
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
|
||||||
.filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search))
|
|
||||||
.slice(0, 20)
|
|
||||||
.map((row) => pokemonFetchOption(row, data, languages, locale));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayValue(value: string | null | undefined): string {
|
function displayValue(value: string | null | undefined): string {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
@@ -988,8 +1008,11 @@ export const api = {
|
|||||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||||
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
|
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
||||||
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
|
getJson<PokemonFetchOption[]>(
|
||||||
|
`/api/pokemon/fetch-options${buildQuery({ search: search.trim(), all: all ? true : undefined })}`,
|
||||||
|
signal
|
||||||
|
),
|
||||||
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
|
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
|
||||||
fetchPokemonImageOptions: (identifier: string) =>
|
fetchPokemonImageOptions: (identifier: string) =>
|
||||||
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),
|
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ const message = ref('');
|
|||||||
const fetchInput = ref<HTMLInputElement | null>(null);
|
const fetchInput = ref<HTMLInputElement | null>(null);
|
||||||
const fetchIdentifier = ref('');
|
const fetchIdentifier = ref('');
|
||||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||||
|
const allFetchOptions = ref<PokemonFetchOption[]>([]);
|
||||||
|
const fetchOptionsLoaded = ref(false);
|
||||||
|
const fetchOptionsFailed = ref(false);
|
||||||
const fetchResultsStyle = ref<CSSProperties>({});
|
const fetchResultsStyle = ref<CSSProperties>({});
|
||||||
const imageOptions = ref<PokemonImage[]>([]);
|
const imageOptions = ref<PokemonImage[]>([]);
|
||||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||||
@@ -62,6 +65,7 @@ const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
|||||||
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
let fetchOptionsController: AbortController | null = null;
|
let fetchOptionsController: AbortController | null = null;
|
||||||
let fetchPositionFrame = 0;
|
let fetchPositionFrame = 0;
|
||||||
|
const fetchOptionsLimit = 20;
|
||||||
|
|
||||||
function defaultPokemonStats(): PokemonStats {
|
function defaultPokemonStats(): PokemonStats {
|
||||||
return {
|
return {
|
||||||
@@ -253,14 +257,9 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
|
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
|
||||||
if (isEditing.value && fetchedPokemon.id !== pokemonIdForSave()) {
|
|
||||||
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pokemonForm.value = {
|
pokemonForm.value = {
|
||||||
...pokemonForm.value,
|
...pokemonForm.value,
|
||||||
id: isEditing.value ? pokemonForm.value.id : String(fetchedPokemon.id),
|
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
|
||||||
name: fetchedPokemon.name,
|
name: fetchedPokemon.name,
|
||||||
genus: fetchedPokemon.genus,
|
genus: fetchedPokemon.genus,
|
||||||
heightInches: fetchedPokemon.heightInches,
|
heightInches: fetchedPokemon.heightInches,
|
||||||
@@ -333,24 +332,57 @@ function cancelFetchOptionsRequest() {
|
|||||||
fetchOptionsLoading.value = false;
|
fetchOptionsLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetFetchOptionsCache() {
|
||||||
|
cancelFetchOptionsRequest();
|
||||||
|
allFetchOptions.value = [];
|
||||||
|
fetchOptions.value = [];
|
||||||
|
fetchOptionsLoaded.value = false;
|
||||||
|
fetchOptionsFailed.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchOptionLabel(option: PokemonFetchOption) {
|
function fetchOptionLabel(option: PokemonFetchOption) {
|
||||||
return `#${option.id} ${option.name}`;
|
return `#${option.id} ${option.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchOptionMatchesSearch(option: PokemonFetchOption, search: string) {
|
||||||
|
if (!search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = search.toLowerCase();
|
||||||
|
return [String(option.id), option.identifier, option.name].some((value) => value.toLowerCase().includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocalFetchOptions() {
|
||||||
|
const search = fetchIdentifier.value.trim();
|
||||||
|
fetchOptions.value = allFetchOptions.value.filter((option) => fetchOptionMatchesSearch(option, search)).slice(0, fetchOptionsLimit);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFetchOptions() {
|
async function loadFetchOptions() {
|
||||||
if (!canFetchPokemon.value) {
|
if (!canFetchPokemon.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
|
||||||
|
applyLocalFetchOptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchOptionsController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cancelFetchOptionsRequest();
|
cancelFetchOptionsRequest();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fetchOptionsController = controller;
|
fetchOptionsController = controller;
|
||||||
fetchOptionsLoading.value = true;
|
fetchOptionsLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
|
const rows = await api.pokemonFetchOptions('', controller.signal, true);
|
||||||
if (fetchOptionsController === controller) {
|
if (fetchOptionsController === controller) {
|
||||||
fetchOptions.value = rows;
|
allFetchOptions.value = rows;
|
||||||
|
fetchOptionsLoaded.value = true;
|
||||||
|
applyLocalFetchOptions();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
@@ -358,6 +390,7 @@ async function loadFetchOptions() {
|
|||||||
}
|
}
|
||||||
if (fetchOptionsController === controller) {
|
if (fetchOptionsController === controller) {
|
||||||
fetchOptions.value = [];
|
fetchOptions.value = [];
|
||||||
|
fetchOptionsFailed.value = true;
|
||||||
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
|
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -373,6 +406,11 @@ function refreshFetchOptions() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
|
||||||
|
applyLocalFetchOptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void loadFetchOptions();
|
void loadFetchOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +489,9 @@ function closeFetchOptions() {
|
|||||||
fetchResultsStyle.value = {};
|
fetchResultsStyle.value = {};
|
||||||
removeFetchPositionListeners();
|
removeFetchPositionListeners();
|
||||||
cancelFetchOptionsRequest();
|
cancelFetchOptionsRequest();
|
||||||
|
if (!fetchOptionsLoaded.value) {
|
||||||
|
fetchOptionsFailed.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFetchIdentifierInput() {
|
function handleFetchIdentifierInput() {
|
||||||
@@ -477,7 +518,7 @@ async function fetchPokemonByIdentifier(identifierValue?: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
|
const identifier = (identifierValue ?? fetchIdentifier.value).trim();
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||||
return;
|
return;
|
||||||
@@ -552,7 +593,7 @@ async function fetchPokemonImages() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
const identifier = fetchIdentifier.value.trim();
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||||
return;
|
return;
|
||||||
@@ -563,12 +604,6 @@ async function fetchPokemonImages() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.fetchPokemonImageOptions(identifier);
|
const result = await api.fetchPokemonImageOptions(identifier);
|
||||||
const currentId = pokemonIdForSave();
|
|
||||||
if (Number.isInteger(currentId) && currentId > 0 && result.id !== currentId) {
|
|
||||||
message.value = t('pages.pokemon.fetchIdMismatch', { id: result.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchIdentifier.value = result.identifier;
|
fetchIdentifier.value = result.identifier;
|
||||||
imageOptions.value = result.images;
|
imageOptions.value = result.images;
|
||||||
|
|
||||||
@@ -681,6 +716,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||||
watch(fetchIdentifier, refreshFetchOptions);
|
watch(fetchIdentifier, refreshFetchOptions);
|
||||||
|
watch(locale, () => {
|
||||||
|
resetFetchOptionsCache();
|
||||||
|
refreshFetchOptions();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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