mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
perf(cap): add runtime settings snapshot for dynamic config
Replace per-request GetByKey calls with a singleflight-backed RuntimeSettings snapshot loaded via ListSystemConfigsByKeys. Invalidate snapshot on admin cap_* writes and via Redis pub/sub. Simplify VerifyMiddleware and update PERFORMANCE.md status.
This commit is contained in:
+84
-52
@@ -11,6 +11,8 @@
|
|||||||
| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ |
|
| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ |
|
||||||
| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ |
|
| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ |
|
||||||
| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
|
| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
|
||||||
|
| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ |
|
||||||
|
| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,13 +48,33 @@ flowchart LR
|
|||||||
J -->|是| L[返回]
|
J -->|是| L[返回]
|
||||||
end
|
end
|
||||||
|
|
||||||
C -.->|串行阻塞| D
|
C -.->|已解除阻塞| D
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数中心读路径**(`SystemConfig.GetByKey`):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
R[业务调用 GetByKey] --> A{RAM 命中?}
|
||||||
|
A -->|是| Z[返回]
|
||||||
|
A -->|否| B{Redis HGET 命中?}
|
||||||
|
B -->|是| C[写入 RAM]
|
||||||
|
C --> Z
|
||||||
|
B -->|否| D[查 PostgreSQL]
|
||||||
|
D --> E[回写 Redis + RAM]
|
||||||
|
E --> Z
|
||||||
|
|
||||||
|
W[管理员 Create/Update] --> F[写 DB]
|
||||||
|
F --> G["InvalidateSystemConfigCache(key)"]
|
||||||
|
G --> H[清本机 RAM + Redis field]
|
||||||
|
G --> I[pub/sub 通知其他节点清 RAM]
|
||||||
```
|
```
|
||||||
|
|
||||||
当前最大的结构性问题(2026-06-17 更新):
|
当前最大的结构性问题(2026-06-17 更新):
|
||||||
|
|
||||||
1. **前端**:全客户端渲染仍为主模式;~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态。Admin 大 bundle、无 `dynamic()` 分割仍待优化。
|
1. **前端**:全客户端渲染仍为主模式;~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态。Admin 大 bundle、无 `dynamic()` 分割仍待优化。
|
||||||
2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
|
2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
|
||||||
|
3. **参数中心**:~~`GetByKey` 每次直打 Redis~~ ✅ 已加 Otter v2 进程内缓存(`pkg/cache/ram`),读路径为 RAM → Redis → DB;管理员写配置后统一失效 RAM + Redis,并通过 pub/sub 同步多节点本地缓存。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -300,25 +322,26 @@ if (loading || !user) {
|
|||||||
| # | 问题 | 位置 | 影响 |
|
| # | 问题 | 位置 | 影响 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 |
|
| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 |
|
||||||
| 2 | CAPTCHA 每次 5 次独立 `GetByKey` | `internal/apps/cap/manager.go` | 登录高峰 Redis 压力 |
|
| 2 | ~~CAPTCHA 每次 5 次独立 `GetByKey`~~ ✅ | `internal/apps/cap/runtime_settings.go` | ~~登录高峰 5× 配置读取~~ → `CurrentSettings` 快照一次加载 6 个 key,`Generate`/`Redeem`/中间件零 `GetByKey` |
|
||||||
| 3 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP |
|
| 3 | ~~系统配置单 key 无进程内缓存~~ ✅ | `system_config_cache.go`, `pkg/cache/ram` | ~~热路径重复 Redis HGET~~ → Otter RAM + 写后 `InvalidateSystemConfigCache` + pub/sub |
|
||||||
| 4 | CORS 每次跨域查 `server_address` 配置 | `internal/router/middlewares.go:75` | 预检请求放大 |
|
| 4 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP |
|
||||||
| 5 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 |
|
| 5 | CORS 每次跨域查 `server_address` 配置 `🔶` | `internal/router/middlewares.go:75` | 预检请求仍每次调用 `GetByKey`,但 `server_address` 已受益于 RAM 缓存 |
|
||||||
| 6 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 |
|
| 6 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 |
|
||||||
| 7 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) |
|
| 7 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 |
|
||||||
| 8 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 |
|
| 8 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) |
|
||||||
| 9 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 |
|
| 9 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 |
|
||||||
| 10 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 |
|
| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 |
|
||||||
| 11 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 |
|
| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 |
|
||||||
| 12 | 无 `next/dynamic` 代码分割 | 全项目 | Admin 首包 300–450KB+ |
|
| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 |
|
||||||
| 13 | 19/24 个 `page.tsx` 为 `"use client"` | 各路由 | 无法 RSC 预取,bundle 偏大 |
|
| 13 | 无 `next/dynamic` 代码分割 | 全项目 | Admin 首包 300–450KB+ |
|
||||||
| 14 | Admin 部分页面用 `useEffect` 而非 React Query | `access-logs.tsx`, `task-executions.tsx` 等 | 无缓存去重,重复请求 |
|
| 14 | 19/24 个 `page.tsx` 为 `"use client"` | 各路由 | 无法 RSC 预取,bundle 偏大 |
|
||||||
| 15 | 登录页 OIDC sources 等待 public config | `frontend/components/auth/login-form.tsx` | 多 1 次 RTT 瀑布 |
|
| 15 | Admin 部分页面用 `useEffect` 而非 React Query | `access-logs.tsx`, `task-executions.tsx` 等 | 无缓存去重,重复请求 |
|
||||||
| 16 | Users 表每行嵌套 3 个 `TooltipProvider` | `frontend/app/(main)/admin/users/page.tsx` | 50+ 行时不必要重渲染 |
|
| 16 | 登录页 OIDC sources 等待 public config | `frontend/components/auth/login-form.tsx` | 多 1 次 RTT 瀑布 |
|
||||||
| 17 | 缩略图用原生 `<img>` 无 lazy loading | `file-list.tsx`, `file-manager.tsx` | 文件管理页初始解码压力大 |
|
| 17 | Users 表每行嵌套 3 个 `TooltipProvider` | `frontend/app/(main)/admin/users/page.tsx` | 50+ 行时不必要重渲染 |
|
||||||
| 18 | `@/lib/services` barrel 导入 | ~40 个文件 | 单路由 bundle 膨胀 10–30KB |
|
| 18 | 缩略图用原生 `<img>` 无 lazy loading | `file-list.tsx`, `file-manager.tsx` | 文件管理页初始解码压力大 |
|
||||||
| 19 | SQLite 模式无连接池调优 | `internal/db/postgres.go` | 默认 SQLite 写锁瓶颈 |
|
| 19 | `@/lib/services` barrel 导入 | ~40 个文件 | 单路由 bundle 膨胀 10–30KB |
|
||||||
| 20 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 |
|
| 20 | SQLite 模式无连接池调优 | `internal/db/postgres.go` | 默认 SQLite 写锁瓶颈 |
|
||||||
|
| 21 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -342,12 +365,13 @@ if (loading || !user) {
|
|||||||
|---|--------|----------|------|
|
|---|--------|----------|------|
|
||||||
| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 |
|
| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 |
|
||||||
| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ |
|
| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ |
|
||||||
| 10 | CAPTCHA 配置快照(一次加载 5 个 key) | 验证码路径 Redis ops ↓ 80% | ⬜ |
|
| 10 | 系统配置 Otter RAM 缓存 + 统一失效 | 热路径 `GetByKey` 零 Redis RTT(命中后) | ✅ |
|
||||||
| 11 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
|
| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ |
|
||||||
| 12 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
|
| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
|
||||||
| 13 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ |
|
| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
|
||||||
| 14 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ |
|
| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ |
|
||||||
| 15 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
|
| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ |
|
||||||
|
| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
|
||||||
|
|
||||||
### P2 — 中期架构演进
|
### P2 — 中期架构演进
|
||||||
|
|
||||||
@@ -370,28 +394,30 @@ if (loading || !user) {
|
|||||||
|
|
||||||
| # | 设计 | 位置 |
|
| # | 设计 | 位置 |
|
||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 1 | 系统配置单 key Redis Hash 缓存 | `internal/model/system_configs.go` — `GetByKey` |
|
| 1 | 系统配置三层缓存 RAM → Redis → DB | `pkg/cache/ram`, `system_config_cache.go`, `GetByKey` |
|
||||||
| 2 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/storage/storage.go` — `Active()` |
|
| 2 | 系统配置统一失效 + 多节点 pub/sub | `InvalidateSystemConfigCache`, `InvalidateAllSystemConfigCaches` |
|
||||||
| 3 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` |
|
| 3 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/storage/storage.go` — `Active()` |
|
||||||
| 4 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` |
|
| 4 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` |
|
||||||
| 5 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` |
|
| 5 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` |
|
||||||
| 6 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/db/` |
|
| 6 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` |
|
||||||
| 7 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup |
|
| 7 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/db/` |
|
||||||
| 8 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` |
|
| 8 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup |
|
||||||
| 9 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` |
|
| 9 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` |
|
||||||
| 10 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` |
|
| 10 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` |
|
||||||
| 11 | 无 GORM `Preload` 滥用 | 全项目 |
|
| 11 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` |
|
||||||
| 12 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` |
|
| 12 | 无 GORM `Preload` 滥用 | 全项目 |
|
||||||
| 13 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` |
|
| 13 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` |
|
||||||
| 14 | React Compiler 已启用 | `frontend/next.config.ts` |
|
| 14 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` |
|
||||||
| 15 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` |
|
| 15 | React Compiler 已启用 | `frontend/next.config.ts` |
|
||||||
| 16 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` |
|
| 16 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` |
|
||||||
| 17 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` |
|
| 17 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` |
|
||||||
| 18 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook |
|
| 18 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` |
|
||||||
| 19 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` |
|
| 19 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook |
|
||||||
| 20 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` |
|
| 20 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` |
|
||||||
| 21 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` |
|
| 21 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` |
|
||||||
| 22 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` |
|
| 22 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` |
|
||||||
|
| 23 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` |
|
||||||
|
| 24 | CAPTCHA 运行时配置快照 + 批量加载 | `runtime_settings.go`, `ListSystemConfigsByKeys` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -403,8 +429,9 @@ if (loading || !user) {
|
|||||||
| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
|
| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
|
||||||
| 管理端日常使用 | 大 bundle(认证/日志已优化) | P0 #7 dynamic import |
|
| 管理端日常使用 | 大 bundle(认证/日志已优化) | P0 #7 dynamic import |
|
||||||
| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
|
| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
|
||||||
| 登录高峰 | CAPTCHA 5×Redis + OIDC discovery | P1 #10, #11 |
|
| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC |
|
||||||
| 多租户 / 跨域前端 | CORS 配置查询 | P1 CORS 缓存 |
|
| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 |
|
||||||
|
| 参数热更新 | 多节点 RAM 一致性 | ✅ `system:config_invalidation` pub/sub |
|
||||||
| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 |
|
| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -436,7 +463,8 @@ P0 前端
|
|||||||
|
|
||||||
P1
|
P1
|
||||||
[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
|
[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
|
||||||
[ ] CAPTCHA 配置快照
|
[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17
|
||||||
|
[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17
|
||||||
[ ] OIDC Provider 缓存
|
[ ] OIDC Provider 缓存
|
||||||
[ ] Admin useEffect → React Query 统一
|
[ ] Admin useEffect → React Query 统一
|
||||||
[ ] 状态轮询 visibility 感知
|
[ ] 状态轮询 visibility 感知
|
||||||
@@ -454,8 +482,12 @@ P1
|
|||||||
| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub |
|
| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub |
|
||||||
| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` |
|
| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` |
|
||||||
| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 |
|
| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 |
|
||||||
|
| RAM 缓存封装 | `pkg/cache/ram/cache.go` | ✅ Otter v2 薄封装 |
|
||||||
|
| 系统配置缓存 | `internal/model/system_config_cache.go` | ✅ RAM + 失效 + pub/sub |
|
||||||
|
| 参数失效 API | `InvalidateSystemConfigCache` | ✅ 清 RAM + Redis field |
|
||||||
|
| CAPTCHA 快照 | `internal/apps/cap/runtime_settings.go` | ✅ `CurrentSettings` + pub/sub |
|
||||||
| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP |
|
| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP |
|
||||||
| 上传索引 | `internal/db/migrator/goose/*/202606090001_initial_schema.sql` | 缺失复合索引 |
|
| 上传索引 | `internal/db/migrator/goose/*202606170001*.sql` | ✅ 复合索引已加 |
|
||||||
| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` |
|
| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` |
|
||||||
| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 |
|
| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 |
|
||||||
| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch |
|
| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
@@ -93,9 +94,7 @@ func CreateSystemConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.InvalidateSystemConfigCache(c.Request.Context(), req.Key); err != nil {
|
invalidateSystemConfigCaches(c.Request.Context(), req.Key)
|
||||||
logger.WarnF(c.Request.Context(), "清理系统配置缓存失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
|
if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
|
||||||
logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
|
logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
|
||||||
@@ -282,10 +281,17 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
func invalidateSystemConfigCaches(ctx context.Context, key string) {
|
||||||
if err := model.InvalidateSystemConfigCache(ctx, key); err != nil {
|
if err := model.InvalidateSystemConfigCache(ctx, key); err != nil {
|
||||||
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if cap.IsRuntimeConfigKey(key) {
|
||||||
|
cap.InvalidateRuntimeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||||||
|
invalidateSystemConfigCaches(ctx, key)
|
||||||
|
|
||||||
if key == model.ConfigKeyStorageConfig {
|
if key == model.ConfigKeyStorageConfig {
|
||||||
upload.ResetAccessCaches()
|
upload.ResetAccessCaches()
|
||||||
|
|||||||
+40
-126
@@ -15,73 +15,47 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/config"
|
"github.com/Rain-kl/Wavelet/internal/config"
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
|
||||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
managerDefaultChallengeCount = 1
|
redeemTokenIDLength = 8 // 兑换 Token ID 字节长度
|
||||||
managerDefaultChallengeSize = 32
|
redeemVerTokenLength = 15 // 兑换验证 Token 字节长度
|
||||||
defaultChallengeDifficulty = 4
|
tokenPartsCount = 2 // 兑换 Token 由两部分组成
|
||||||
defaultChallengeTTL = 10 * time.Minute
|
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
||||||
defaultTokenTTL = 20 * time.Minute
|
|
||||||
redeemTokenIDLength = 8 // 兑换 Token ID 字节长度
|
|
||||||
redeemVerTokenLength = 15 // 兑换验证 Token 字节长度
|
|
||||||
tokenPartsCount = 2 // 兑换 Token 由两部分组成
|
|
||||||
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds settings for the CAPTCHA manager
|
// Manager orchestrates challenge generation and solution validation.
|
||||||
type Config struct {
|
|
||||||
Secret []byte // HMAC signing key
|
|
||||||
ChallengeCount int // Number of PoW puzzles
|
|
||||||
ChallengeSize int // Size of the salt string
|
|
||||||
ChallengeDifficulty int // Length of difficulty target prefix
|
|
||||||
ChallengeTTL time.Duration // Lifespan of the challenge JWT
|
|
||||||
TokenTTL time.Duration // Lifespan of the redeem token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager orchestrates challenge generation and solution validation
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
conf Config
|
secret []byte
|
||||||
store pkgcap.Store
|
store pkgcap.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new CAPTCHA Manager
|
// NewManager creates a new CAPTCHA Manager.
|
||||||
func NewManager(conf Config, store pkgcap.Store) *Manager {
|
func NewManager(secret []byte, store pkgcap.Store) *Manager {
|
||||||
if conf.ChallengeCount <= 0 {
|
|
||||||
conf.ChallengeCount = managerDefaultChallengeCount
|
|
||||||
}
|
|
||||||
if conf.ChallengeSize <= 0 {
|
|
||||||
conf.ChallengeSize = managerDefaultChallengeSize
|
|
||||||
}
|
|
||||||
if conf.ChallengeDifficulty <= 0 {
|
|
||||||
conf.ChallengeDifficulty = defaultChallengeDifficulty
|
|
||||||
}
|
|
||||||
if conf.ChallengeTTL <= 0 {
|
|
||||||
conf.ChallengeTTL = defaultChallengeTTL
|
|
||||||
}
|
|
||||||
if conf.TokenTTL <= 0 {
|
|
||||||
conf.TokenTTL = defaultTokenTTL
|
|
||||||
}
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
conf: conf,
|
secret: secret,
|
||||||
store: store,
|
store: store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates a challenge response
|
// Generate creates a challenge response.
|
||||||
func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) {
|
func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) {
|
||||||
c := pkgcap.ChallengeConfig{
|
settings, err := CurrentSettings(ctx)
|
||||||
Count: m.getChallengeCount(ctx),
|
if err != nil {
|
||||||
Size: m.getChallengeSize(ctx),
|
return nil, err
|
||||||
Difficulty: m.getChallengeDifficulty(ctx),
|
|
||||||
Expires: m.getChallengeTTL(ctx),
|
|
||||||
}
|
}
|
||||||
return pkgcap.GenerateChallenge(m.conf.Secret, c, scope)
|
|
||||||
|
challengeConfig := pkgcap.ChallengeConfig{
|
||||||
|
Count: settings.ChallengeCount,
|
||||||
|
Size: settings.ChallengeSize,
|
||||||
|
Difficulty: settings.ChallengeDifficulty,
|
||||||
|
Expires: settings.ChallengeTTL,
|
||||||
|
}
|
||||||
|
return pkgcap.GenerateChallenge(m.secret, challengeConfig, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedeemResponse is returned to the client on redeem
|
// RedeemResponse is returned to the client on redeem.
|
||||||
type RedeemResponse struct {
|
type RedeemResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
@@ -89,7 +63,7 @@ type RedeemResponse struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redeem verifies PoW solutions and returns a one-time redeem token
|
// Redeem verifies PoW solutions and returns a one-time redeem token.
|
||||||
func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) {
|
func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) {
|
||||||
sigHex := pkgcap.JwtSigHex(token)
|
sigHex := pkgcap.JwtSigHex(token)
|
||||||
if sigHex == "" {
|
if sigHex == "" {
|
||||||
@@ -98,21 +72,17 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
|
|||||||
|
|
||||||
nonceKey := "cap:nonce:" + sigHex
|
nonceKey := "cap:nonce:" + sigHex
|
||||||
|
|
||||||
// Atomically claim the nonce slot BEFORE verifying solutions.
|
payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.secret, scope)
|
||||||
payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // expected behavior: validation error is returned as response, not system error
|
return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // validation errors are returned as response, not system errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate remaining lifetime of the challenge JWT for the nonce TTL.
|
|
||||||
now := time.Now().UnixNano() / int64(time.Millisecond)
|
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||||
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
|
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
|
||||||
if nonceTTL < time.Second {
|
if nonceTTL < time.Second {
|
||||||
nonceTTL = time.Second
|
nonceTTL = time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic claim: if another goroutine already redeemed this JWT the SetNX
|
|
||||||
// will return false and we reject the request without issuing a token.
|
|
||||||
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
|
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
|
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
|
||||||
@@ -121,20 +91,21 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
|
|||||||
return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil
|
return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a redeem token formatted as "id:verToken"
|
settings, err := CurrentSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return &RedeemResponse{Success: false, Error: "settings_load_error"}, err
|
||||||
|
}
|
||||||
|
|
||||||
id := pkgcap.RandomHex(redeemTokenIDLength)
|
id := pkgcap.RandomHex(redeemTokenIDLength)
|
||||||
verToken := pkgcap.RandomHex(redeemVerTokenLength)
|
verToken := pkgcap.RandomHex(redeemVerTokenLength)
|
||||||
verHashBytes := sha256.Sum256([]byte(verToken))
|
verHashBytes := sha256.Sum256([]byte(verToken))
|
||||||
verHashHex := hex.EncodeToString(verHashBytes[:])
|
verHashHex := hex.EncodeToString(verHashBytes[:])
|
||||||
|
|
||||||
tokenKey := "cap:token:" + id + ":" + verHashHex
|
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||||
tokenTTL := m.getTokenTTL(ctx)
|
tokenExpires := time.Now().Add(settings.TokenTTL)
|
||||||
tokenExpires := time.Now().Add(tokenTTL)
|
|
||||||
|
|
||||||
// Value stored is "expiresNano|scope"
|
|
||||||
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
|
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
|
||||||
|
|
||||||
if err := m.store.Set(ctx, tokenKey, storeVal, tokenTTL); err != nil {
|
if err := m.store.Set(ctx, tokenKey, storeVal, settings.TokenTTL); err != nil {
|
||||||
return &RedeemResponse{Success: false, Error: "token_store_error"}, err
|
return &RedeemResponse{Success: false, Error: "token_store_error"}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +133,6 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
|||||||
|
|
||||||
tokenKey := "cap:token:" + id + ":" + verHashHex
|
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||||
|
|
||||||
// Atomically retrieve-and-delete
|
|
||||||
val, exists, err := sGetAndDelete(ctx, m.store, tokenKey)
|
val, exists, err := sGetAndDelete(ctx, m.store, tokenKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -178,7 +148,7 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
|||||||
|
|
||||||
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
|
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil //nolint:nilerr // expected behavior: invalid format is treated as validation failure, not system error
|
return false, nil //nolint:nilerr // invalid format is treated as validation failure
|
||||||
}
|
}
|
||||||
tokenScope := valParts[1]
|
tokenScope := valParts[1]
|
||||||
|
|
||||||
@@ -187,13 +157,12 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().UnixNano() > expNano {
|
if time.Now().UnixNano() > expNano {
|
||||||
return false, nil // Expired
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sGetAndDelete safely calls store.GetAndDelete, treating a nil store as a miss.
|
|
||||||
func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, bool, error) {
|
func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, bool, error) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
@@ -201,67 +170,19 @@ func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string,
|
|||||||
return store.GetAndDelete(ctx, key)
|
return store.GetAndDelete(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getChallengeCount(ctx context.Context) int {
|
|
||||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeCount)
|
|
||||||
if err != nil || val <= 0 {
|
|
||||||
return m.conf.ChallengeCount
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) getChallengeSize(ctx context.Context) int {
|
|
||||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeSize)
|
|
||||||
if err != nil || val <= 0 {
|
|
||||||
return m.conf.ChallengeSize
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) getChallengeDifficulty(ctx context.Context) int {
|
|
||||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeDifficulty)
|
|
||||||
if err != nil || val <= 0 {
|
|
||||||
return m.conf.ChallengeDifficulty
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) getChallengeTTL(ctx context.Context) time.Duration {
|
|
||||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeTTL)
|
|
||||||
if err != nil || val <= 0 {
|
|
||||||
return m.conf.ChallengeTTL
|
|
||||||
}
|
|
||||||
return time.Duration(val) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) getTokenTTL(ctx context.Context) time.Duration {
|
|
||||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapTokenTTL)
|
|
||||||
if err != nil || val <= 0 {
|
|
||||||
return m.conf.TokenTTL
|
|
||||||
}
|
|
||||||
return time.Duration(val) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
defaultManager *Manager
|
defaultManager *Manager
|
||||||
once sync.Once
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDefaultManager yields the global singleton CAPTCHA manager
|
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
||||||
func GetDefaultManager() *Manager {
|
func GetDefaultManager() *Manager {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
var secret []byte
|
secret := []byte("default-captcha-secret-key-at-least-16-bytes")
|
||||||
if config.Config != nil && config.Config.App.SessionSecret != "" {
|
if config.Config != nil && config.Config.App.SessionSecret != "" {
|
||||||
secret = []byte(config.Config.App.SessionSecret)
|
secret = []byte(config.Config.App.SessionSecret)
|
||||||
} else {
|
|
||||||
secret = []byte("default-captcha-secret-key-at-least-16-bytes")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
challengeCount := managerDefaultChallengeCount
|
|
||||||
challengeSize := managerDefaultChallengeSize
|
|
||||||
challengeDifficulty := defaultChallengeDifficulty
|
|
||||||
challengeTTL := defaultChallengeTTL
|
|
||||||
tokenTTL := defaultTokenTTL
|
|
||||||
|
|
||||||
var store pkgcap.Store
|
var store pkgcap.Store
|
||||||
if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil {
|
if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil {
|
||||||
store = pkgcap.NewRedisStore(db.Redis)
|
store = pkgcap.NewRedisStore(db.Redis)
|
||||||
@@ -269,14 +190,7 @@ func GetDefaultManager() *Manager {
|
|||||||
store = pkgcap.NewMemoryStore(1 * time.Minute)
|
store = pkgcap.NewMemoryStore(1 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultManager = NewManager(Config{
|
defaultManager = NewManager(secret, store)
|
||||||
Secret: secret,
|
|
||||||
ChallengeCount: challengeCount,
|
|
||||||
ChallengeSize: challengeSize,
|
|
||||||
ChallengeDifficulty: challengeDifficulty,
|
|
||||||
ChallengeTTL: challengeTTL,
|
|
||||||
TokenTTL: tokenTTL,
|
|
||||||
}, store)
|
|
||||||
})
|
})
|
||||||
return defaultManager
|
return defaultManager
|
||||||
}
|
}
|
||||||
@@ -13,151 +13,141 @@ import (
|
|||||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCapFullFlow(t *testing.T) {
|
func installTestManagerSettings(t *testing.T) func() {
|
||||||
secret := []byte("a-very-long-secret-key-at-least-16-bytes")
|
t.Helper()
|
||||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
return InstallTestRuntimeSettings(RuntimeSettings{
|
||||||
|
ChallengeCount: 3,
|
||||||
manager := NewManager(Config{
|
|
||||||
Secret: secret,
|
|
||||||
ChallengeCount: 3, // small count for fast test
|
|
||||||
ChallengeSize: 32,
|
ChallengeSize: 32,
|
||||||
ChallengeDifficulty: 3, // small difficulty for fast test
|
ChallengeDifficulty: 3,
|
||||||
ChallengeTTL: 5 * time.Second,
|
ChallengeTTL: 5 * time.Second,
|
||||||
TokenTTL: 10 * time.Second,
|
TokenTTL: 10 * time.Second,
|
||||||
}, store)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapFullFlow(t *testing.T) {
|
||||||
|
cleanup := installTestManagerSettings(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
secret := []byte("a-very-long-secret-key-at-least-16-bytes")
|
||||||
|
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||||
|
manager := NewManager(secret, store)
|
||||||
|
|
||||||
scope := "test-scope"
|
scope := "test-scope"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
resp, err := manager.Generate(ctx, scope)
|
resp, err := manager.Generate(ctx, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate failed: %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Challenge.C != 3 {
|
if resp.Challenge.C != 3 {
|
||||||
t.Errorf("Expected count 3, got %d", resp.Challenge.C)
|
t.Fatalf("Generate().Challenge.C = %d, want %d", resp.Challenge.C, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solve the challenge (acting as client)
|
|
||||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||||
|
|
||||||
// Redeem
|
|
||||||
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope)
|
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Redeem failed: %v", err)
|
t.Fatalf("Redeem() error = %v", err)
|
||||||
}
|
}
|
||||||
if !redeemResp.Success {
|
if !redeemResp.Success {
|
||||||
t.Fatalf("Redeem returned success=false: %s", redeemResp.Error)
|
t.Fatalf("Redeem().Success = false, error = %s", redeemResp.Error)
|
||||||
}
|
}
|
||||||
if redeemResp.Token == "" {
|
if redeemResp.Token == "" {
|
||||||
t.Fatalf("Expected token, got empty")
|
t.Fatal("Redeem().Token is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the token
|
|
||||||
valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VerifyToken failed: %v", err)
|
t.Fatalf("VerifyToken() error = %v", err)
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
t.Fatalf("Expected redeem token to be valid")
|
t.Fatal("VerifyToken() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token is one-time use
|
|
||||||
validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VerifyToken second call failed: %v", err)
|
t.Fatalf("VerifyToken() second call error = %v", err)
|
||||||
}
|
}
|
||||||
if validAgain {
|
if validAgain {
|
||||||
t.Fatalf("Expected redeem token to be single-use (invalidated after verification)")
|
t.Fatal("VerifyToken() second call = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRedeemConcurrentRace verifies that when N goroutines simultaneously call
|
|
||||||
// Redeem with the same challenge JWT, exactly one succeeds and the rest are
|
|
||||||
// rejected with "already_redeemed". This guards against the TOCTOU fix.
|
|
||||||
func TestRedeemConcurrentRace(t *testing.T) {
|
func TestRedeemConcurrentRace(t *testing.T) {
|
||||||
const goroutines = 50
|
const goroutines = 50
|
||||||
|
|
||||||
|
cleanup := installTestManagerSettings(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
||||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||||
manager := NewManager(Config{
|
manager := NewManager(secret, store)
|
||||||
Secret: secret,
|
|
||||||
ChallengeCount: 1,
|
|
||||||
ChallengeSize: 32,
|
|
||||||
ChallengeDifficulty: 3,
|
|
||||||
ChallengeTTL: 30 * time.Second,
|
|
||||||
TokenTTL: 30 * time.Second,
|
|
||||||
}, store)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
resp, err := manager.Generate(ctx, "login")
|
resp, err := manager.Generate(ctx, "login")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate failed: %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||||
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
success atomic.Int32
|
|
||||||
barrier = make(chan struct{}) // synchronise goroutine start
|
|
||||||
)
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
<-barrier // wait for the gun
|
|
||||||
r, _ := manager.Redeem(ctx, resp.Token, solutions, "login")
|
|
||||||
if r != nil && r.Success {
|
|
||||||
success.Add(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
close(barrier) // fire all goroutines at once
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if n := success.Load(); n != 1 {
|
|
||||||
t.Fatalf("Expected exactly 1 successful Redeem, got %d", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVerifyTokenConcurrentRace verifies that when N goroutines simultaneously
|
|
||||||
// call VerifyToken with the same cap token, exactly one succeeds and the rest
|
|
||||||
// fail. This guards against the GetAndDelete fix.
|
|
||||||
func TestVerifyTokenConcurrentRace(t *testing.T) {
|
|
||||||
const goroutines = 50
|
|
||||||
|
|
||||||
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
|
||||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
|
||||||
manager := NewManager(Config{
|
|
||||||
Secret: secret,
|
|
||||||
ChallengeCount: 1,
|
|
||||||
ChallengeSize: 32,
|
|
||||||
ChallengeDifficulty: 3,
|
|
||||||
ChallengeTTL: 30 * time.Second,
|
|
||||||
TokenTTL: 30 * time.Second,
|
|
||||||
}, store)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
resp, _ := manager.Generate(ctx, "login")
|
|
||||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
|
||||||
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login")
|
|
||||||
if err != nil || !redeemResp.Success {
|
|
||||||
t.Fatalf("Redeem failed: %v %+v", err, redeemResp)
|
|
||||||
}
|
|
||||||
capToken := redeemResp.Token
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
success atomic.Int32
|
success atomic.Int32
|
||||||
barrier = make(chan struct{})
|
barrier = make(chan struct{})
|
||||||
)
|
)
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
for range goroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-barrier
|
<-barrier
|
||||||
ok, _ := manager.VerifyToken(ctx, capToken, "login")
|
r, _ := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||||
|
if r != nil && r.Success {
|
||||||
|
success.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(barrier)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := success.Load(); got != 1 {
|
||||||
|
t.Fatalf("successful Redeem count = %d, want %d", got, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyTokenConcurrentRace(t *testing.T) {
|
||||||
|
const goroutines = 50
|
||||||
|
|
||||||
|
cleanup := installTestManagerSettings(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
||||||
|
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||||
|
manager := NewManager(secret, store)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
resp, err := manager.Generate(ctx, "login")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||||
|
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||||
|
if err != nil || !redeemResp.Success {
|
||||||
|
t.Fatalf("Redeem() error = %v, resp = %+v", err, redeemResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
success atomic.Int32
|
||||||
|
barrier = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
for range goroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-barrier
|
||||||
|
ok, _ := manager.VerifyToken(ctx, redeemResp.Token, "login")
|
||||||
if ok {
|
if ok {
|
||||||
success.Add(1)
|
success.Add(1)
|
||||||
}
|
}
|
||||||
@@ -166,7 +156,7 @@ func TestVerifyTokenConcurrentRace(t *testing.T) {
|
|||||||
close(barrier)
|
close(barrier)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if n := success.Load(); n != 1 {
|
if got := success.Load(); got != 1 {
|
||||||
t.Fatalf("Expected exactly 1 successful VerifyToken, got %d", n)
|
t.Fatalf("successful VerifyToken count = %d, want %d", got, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
||||||
// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on.
|
func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc {
|
||||||
func VerifyMiddleware(mgr *Manager, scope string, enabledFunc func() bool) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if enabledFunc != nil && !enabledFunc() {
|
if !ProtectionEnabled(c.Request.Context()) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -34,4 +33,4 @@ func VerifyMiddleware(mgr *Manager, scope string, enabledFunc func() bool) gin.H
|
|||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,20 +3,21 @@
|
|||||||
|
|
||||||
package cap
|
package cap
|
||||||
|
|
||||||
import ("bytes"
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
|
||||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/common/response")
|
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||||
|
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCapEndpointsAndMiddleware(t *testing.T) {
|
func TestCapEndpointsAndMiddleware(t *testing.T) {
|
||||||
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
|
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
@@ -32,14 +33,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) {
|
|||||||
capGroup.POST("/redeem", Redeem)
|
capGroup.POST("/redeem", Redeem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login endpoint with CAPTCHA middleware
|
r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login"), func(c *gin.Context) {
|
||||||
r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login", func() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}), func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, response.OK("login success"))
|
c.JSON(http.StatusOK, response.OK("login success"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,15 +63,15 @@ func TestCapEndpointsAndMiddleware(t *testing.T) {
|
|||||||
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Enable CAPTCHA in DB
|
// 3. Enable CAPTCHA in DB and invalidate runtime snapshot
|
||||||
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
|
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
|
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
|
||||||
}
|
}
|
||||||
// Update cache
|
if err := model.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil {
|
||||||
var sysCfg model.SystemConfig
|
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||||
sqliteDB.Where("key = ?", model.ConfigKeyCapLoginEnabled).First(&sysCfg)
|
}
|
||||||
_ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &sysCfg)
|
InvalidateRuntimeSettings()
|
||||||
|
|
||||||
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
|
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultChallengeCount = 1
|
||||||
|
defaultChallengeSize = 32
|
||||||
|
defaultChallengeDifficulty = 4
|
||||||
|
defaultChallengeTTL = 10 * time.Minute
|
||||||
|
defaultTokenTTL = 20 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs.
|
||||||
|
type RuntimeSettings struct {
|
||||||
|
LoginEnabled bool
|
||||||
|
ChallengeCount int
|
||||||
|
ChallengeSize int
|
||||||
|
ChallengeDifficulty int
|
||||||
|
ChallengeTTL time.Duration
|
||||||
|
TokenTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeConfigKeys = []string{
|
||||||
|
model.ConfigKeyCapLoginEnabled,
|
||||||
|
model.ConfigKeyCapChallengeCount,
|
||||||
|
model.ConfigKeyCapChallengeSize,
|
||||||
|
model.ConfigKeyCapChallengeDifficulty,
|
||||||
|
model.ConfigKeyCapChallengeTTL,
|
||||||
|
model.ConfigKeyCapTokenTTL,
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeConfigKeySet = func() map[string]struct{} {
|
||||||
|
set := make(map[string]struct{}, len(runtimeConfigKeys))
|
||||||
|
for _, key := range runtimeConfigKeys {
|
||||||
|
set[key] = struct{}{}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}()
|
||||||
|
|
||||||
|
type runtimeSettingsStore struct {
|
||||||
|
snapshot atomic.Pointer[RuntimeSettings]
|
||||||
|
loadGroup singleflight.Group
|
||||||
|
listenerOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsStore = &runtimeSettingsStore{}
|
||||||
|
|
||||||
|
// IsRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings.
|
||||||
|
func IsRuntimeConfigKey(key string) bool {
|
||||||
|
_, ok := runtimeConfigKeySet[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentSettings returns the cached CAPTCHA runtime settings snapshot.
|
||||||
|
func CurrentSettings(ctx context.Context) (RuntimeSettings, error) {
|
||||||
|
return settingsStore.current(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtectionEnabled reports whether CAPTCHA verification is required for protected routes.
|
||||||
|
func ProtectionEnabled(ctx context.Context) bool {
|
||||||
|
settings, err := CurrentSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return settings.LoginEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateRuntimeSettings drops the in-process CAPTCHA settings snapshot.
|
||||||
|
func InvalidateRuntimeSettings() {
|
||||||
|
settingsStore.snapshot.Store(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRuntimeSettingsForTest clears the CAPTCHA runtime snapshot.
|
||||||
|
func ResetRuntimeSettingsForTest() {
|
||||||
|
InvalidateRuntimeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallTestRuntimeSettings installs a fixed snapshot for unit tests.
|
||||||
|
func InstallTestRuntimeSettings(settings RuntimeSettings) func() {
|
||||||
|
snapshot := settings
|
||||||
|
settingsStore.snapshot.Store(&snapshot)
|
||||||
|
return InvalidateRuntimeSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, error) {
|
||||||
|
s.ensureInvalidationListener()
|
||||||
|
|
||||||
|
if snapshot := s.snapshot.Load(); snapshot != nil {
|
||||||
|
return *snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) {
|
||||||
|
if snapshot := s.snapshot.Load(); snapshot != nil {
|
||||||
|
return *snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, loadErr := loadRuntimeSettings(ctx)
|
||||||
|
if loadErr != nil {
|
||||||
|
return RuntimeSettings{}, loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
s.snapshot.Store(&settings)
|
||||||
|
return settings, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RuntimeSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, ok := loaded.(RuntimeSettings)
|
||||||
|
if !ok {
|
||||||
|
return RuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type")
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) {
|
||||||
|
configs, err := model.ListSystemConfigsByKeys(ctx, runtimeConfigKeys)
|
||||||
|
if err != nil {
|
||||||
|
return RuntimeSettings{}, err
|
||||||
|
}
|
||||||
|
return parseRuntimeSettings(configs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeSettings(configs map[string]model.SystemConfig) RuntimeSettings {
|
||||||
|
settings := RuntimeSettings{
|
||||||
|
ChallengeCount: defaultChallengeCount,
|
||||||
|
ChallengeSize: defaultChallengeSize,
|
||||||
|
ChallengeDifficulty: defaultChallengeDifficulty,
|
||||||
|
ChallengeTTL: defaultChallengeTTL,
|
||||||
|
TokenTTL: defaultTokenTTL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapLoginEnabled]; ok {
|
||||||
|
if enabled, err := strconv.ParseBool(sc.Value); err == nil {
|
||||||
|
settings.LoginEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapChallengeCount]; ok {
|
||||||
|
if count, err := strconv.Atoi(sc.Value); err == nil && count > 0 {
|
||||||
|
settings.ChallengeCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapChallengeSize]; ok {
|
||||||
|
if size, err := strconv.Atoi(sc.Value); err == nil && size > 0 {
|
||||||
|
settings.ChallengeSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapChallengeDifficulty]; ok {
|
||||||
|
if difficulty, err := strconv.Atoi(sc.Value); err == nil && difficulty > 0 {
|
||||||
|
settings.ChallengeDifficulty = difficulty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapChallengeTTL]; ok {
|
||||||
|
if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 {
|
||||||
|
settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc, ok := configs[model.ConfigKeyCapTokenTTL]; ok {
|
||||||
|
if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 {
|
||||||
|
settings.TokenTTL = time.Duration(ttlSeconds) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *runtimeSettingsStore) ensureInvalidationListener() {
|
||||||
|
s.listenerOnce.Do(startRuntimeSettingsInvalidationListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRuntimeSettingsInvalidationListener() {
|
||||||
|
if db.Redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
pubsub := db.Redis.Subscribe(context.Background(), model.SystemConfigInvalidationChannel)
|
||||||
|
defer func() {
|
||||||
|
_ = pubsub.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for msg := range pubsub.Channel() {
|
||||||
|
var payload struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
|
||||||
|
InvalidateRuntimeSettings()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if payload.Key == "" || payload.Key == "*" || IsRuntimeConfigKey(payload.Key) {
|
||||||
|
InvalidateRuntimeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCurrentSettingsLoadsSnapshotOnce(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ResetRuntimeSettingsForTest()
|
||||||
|
model.ResetSystemConfigRAMCacheForTest()
|
||||||
|
|
||||||
|
first, err := CurrentSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentSettings() first error = %v", err)
|
||||||
|
}
|
||||||
|
if first.ChallengeCount != 1 {
|
||||||
|
t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", first.ChallengeCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB(ctx).Model(&model.SystemConfig{}).
|
||||||
|
Where("key = ?", model.ConfigKeyCapChallengeCount).
|
||||||
|
Update("value", "4").Error; err != nil {
|
||||||
|
t.Fatalf("Update(cap_challenge_count) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapChallengeCount); err != nil {
|
||||||
|
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||||
|
}
|
||||||
|
InvalidateRuntimeSettings()
|
||||||
|
|
||||||
|
second, err := CurrentSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentSettings() second error = %v", err)
|
||||||
|
}
|
||||||
|
if second.ChallengeCount != 4 {
|
||||||
|
t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", second.ChallengeCount, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtectionEnabledReflectsLoginSwitch(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ResetRuntimeSettingsForTest()
|
||||||
|
|
||||||
|
if ProtectionEnabled(ctx) {
|
||||||
|
t.Fatal("ProtectionEnabled() = true, want false from seed defaults")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB(ctx).Model(&model.SystemConfig{}).
|
||||||
|
Where("key = ?", model.ConfigKeyCapLoginEnabled).
|
||||||
|
Update("value", "true").Error; err != nil {
|
||||||
|
t.Fatalf("Update(cap_login_enabled) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapLoginEnabled); err != nil {
|
||||||
|
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||||
|
}
|
||||||
|
InvalidateRuntimeSettings()
|
||||||
|
|
||||||
|
if !ProtectionEnabled(ctx) {
|
||||||
|
t.Fatal("ProtectionEnabled() = false, want true after config update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRuntimeSettingsUsesDefaultsForMissingKeys(t *testing.T) {
|
||||||
|
settings := parseRuntimeSettings(map[string]model.SystemConfig{})
|
||||||
|
|
||||||
|
if settings.ChallengeCount != defaultChallengeCount {
|
||||||
|
t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, defaultChallengeCount)
|
||||||
|
}
|
||||||
|
if settings.ChallengeTTL != defaultChallengeTTL {
|
||||||
|
t.Fatalf("ChallengeTTL = %s, want %s", settings.ChallengeTTL, defaultChallengeTTL)
|
||||||
|
}
|
||||||
|
if settings.TokenTTL != defaultTokenTTL {
|
||||||
|
t.Fatalf("TokenTTL = %s, want %s", settings.TokenTTL, defaultTokenTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsRuntimeConfigKey(t *testing.T) {
|
||||||
|
if !IsRuntimeConfigKey(model.ConfigKeyCapChallengeCount) {
|
||||||
|
t.Fatalf("IsRuntimeConfigKey(%s) = false, want true", model.ConfigKeyCapChallengeCount)
|
||||||
|
}
|
||||||
|
if IsRuntimeConfigKey(model.ConfigKeySiteName) {
|
||||||
|
t.Fatalf("IsRuntimeConfigKey(%s) = true, want false", model.ConfigKeySiteName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallTestRuntimeSettings(t *testing.T) {
|
||||||
|
cleanup := InstallTestRuntimeSettings(RuntimeSettings{
|
||||||
|
LoginEnabled: true,
|
||||||
|
ChallengeCount: 2,
|
||||||
|
TokenTTL: 30 * time.Minute,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
settings, err := CurrentSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentSettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if !settings.LoginEnabled {
|
||||||
|
t.Fatal("LoginEnabled = false, want true")
|
||||||
|
}
|
||||||
|
if settings.ChallengeCount != 2 {
|
||||||
|
t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cap
|
||||||
|
|
||||||
|
import "github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
testhelper.RegisterCleanup(ResetRuntimeSettingsForTest)
|
||||||
|
}
|
||||||
@@ -115,6 +115,47 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSystemConfigsByKeys loads multiple config keys in one database round trip.
|
||||||
|
// Keys already present in the process-local RAM cache are returned without querying PostgreSQL.
|
||||||
|
func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]SystemConfig, error) {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return map[string]SystemConfig{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureSystemConfigCacheListener()
|
||||||
|
|
||||||
|
result := make(map[string]SystemConfig, len(keys))
|
||||||
|
missing := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if cached, ok := systemConfigRAMCache.GetIfPresent(key); ok {
|
||||||
|
result[key] = cloneSystemConfig(cached)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
missing = append(missing, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
database := db.DB(ctx)
|
||||||
|
if database == nil {
|
||||||
|
return nil, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs []SystemConfig
|
||||||
|
if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range configs {
|
||||||
|
populateSystemConfigCache(ctx, configs[i])
|
||||||
|
result[configs[i].Key] = cloneSystemConfig(configs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateVisibleSystemConfigsCache clears the cached public config list.
|
// InvalidateVisibleSystemConfigsCache clears the cached public config list.
|
||||||
func InvalidateVisibleSystemConfigsCache(ctx context.Context) error {
|
func InvalidateVisibleSystemConfigsCache(ctx context.Context) error {
|
||||||
if db.Redis == nil {
|
if db.Redis == nil {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -21,9 +20,10 @@ func TestCORSMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
// Helper to clear config cache
|
|
||||||
clearConfigCache := func() {
|
clearConfigCache := func() {
|
||||||
_ = db.Redis.Del(context.Background(), db.PrefixedKey(model.SystemConfigRedisHashKey)).Err()
|
if err := model.InvalidateAllSystemConfigCaches(context.Background()); err != nil {
|
||||||
|
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) {
|
t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) {
|
||||||
|
|||||||
@@ -5,14 +5,11 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||||
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,27 +61,9 @@ func registerOAuthRoutes(apiV1Router *gin.RouterGroup) {
|
|||||||
func registerUserRoutes(apiV1Router *gin.RouterGroup) {
|
func registerUserRoutes(apiV1Router *gin.RouterGroup) {
|
||||||
userRouter := apiV1Router.Group("/user")
|
userRouter := apiV1Router.Group("/user")
|
||||||
{
|
{
|
||||||
userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login", func() bool {
|
userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login"), user.Login)
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
userRouter.POST("/register", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "register"), user.Register)
|
||||||
if err != nil {
|
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "send_email_code"), user.SendEmailCode)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}), user.Login)
|
|
||||||
userRouter.POST("/register", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "register", func() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}), user.Register)
|
|
||||||
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "send_email_code", func() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}), user.SendEmailCode)
|
|
||||||
userRouter.GET("/logout", user.Logout)
|
userRouter.GET("/logout", user.Logout)
|
||||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package testhelper
|
||||||
|
|
||||||
|
// RegisterCleanup registers an extra cleanup hook invoked by SetupTestEnvironment.
|
||||||
|
func RegisterCleanup(fn func()) {
|
||||||
|
extraCleanups = append(extraCleanups, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extraCleanups []func()
|
||||||
|
|
||||||
|
func runExtraCleanups() {
|
||||||
|
for _, fn := range extraCleanups {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
|
runExtraCleanups()
|
||||||
model.ResetSystemConfigRAMCacheForTest()
|
model.ResetSystemConfigRAMCacheForTest()
|
||||||
_ = redisClient.Close()
|
_ = redisClient.Close()
|
||||||
mr.Close()
|
mr.Close()
|
||||||
|
|||||||
Reference in New Issue
Block a user