diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 6ad7151c..5e13c147 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -11,6 +11,8 @@ | P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ | | P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ | | P1 #9 | 公共配置 Redis 列表缓存 | ✅ | +| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ | +| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ | --- @@ -46,13 +48,33 @@ flowchart LR J -->|是| L[返回] 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 更新): 1. **前端**:全客户端渲染仍为主模式;~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态。Admin 大 bundle、无 `dynamic()` 分割仍待优化。 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 时失效 | -| 2 | CAPTCHA 每次 5 次独立 `GetByKey` | `internal/apps/cap/manager.go` | 登录高峰 Redis 压力 | -| 3 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP | -| 4 | CORS 每次跨域查 `server_address` 配置 | `internal/router/middlewares.go:75` | 预检请求放大 | -| 5 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 | -| 6 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 | -| 7 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) | -| 8 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 | -| 9 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 | -| 10 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 | -| 11 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 | -| 12 | 无 `next/dynamic` 代码分割 | 全项目 | Admin 首包 300–450KB+ | -| 13 | 19/24 个 `page.tsx` 为 `"use client"` | 各路由 | 无法 RSC 预取,bundle 偏大 | -| 14 | Admin 部分页面用 `useEffect` 而非 React Query | `access-logs.tsx`, `task-executions.tsx` 等 | 无缓存去重,重复请求 | -| 15 | 登录页 OIDC sources 等待 public config | `frontend/components/auth/login-form.tsx` | 多 1 次 RTT 瀑布 | -| 16 | Users 表每行嵌套 3 个 `TooltipProvider` | `frontend/app/(main)/admin/users/page.tsx` | 50+ 行时不必要重渲染 | -| 17 | 缩略图用原生 `` 无 lazy loading | `file-list.tsx`, `file-manager.tsx` | 文件管理页初始解码压力大 | -| 18 | `@/lib/services` barrel 导入 | ~40 个文件 | 单路由 bundle 膨胀 10–30KB | -| 19 | SQLite 模式无连接池调优 | `internal/db/postgres.go` | 默认 SQLite 写锁瓶颈 | -| 20 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 | +| 2 | ~~CAPTCHA 每次 5 次独立 `GetByKey`~~ ✅ | `internal/apps/cap/runtime_settings.go` | ~~登录高峰 5× 配置读取~~ → `CurrentSettings` 快照一次加载 6 个 key,`Generate`/`Redeem`/中间件零 `GetByKey` | +| 3 | ~~系统配置单 key 无进程内缓存~~ ✅ | `system_config_cache.go`, `pkg/cache/ram` | ~~热路径重复 Redis HGET~~ → Otter RAM + 写后 `InvalidateSystemConfigCache` + pub/sub | +| 4 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP | +| 5 | CORS 每次跨域查 `server_address` 配置 `🔶` | `internal/router/middlewares.go:75` | 预检请求仍每次调用 `GetByKey`,但 `server_address` 已受益于 RAM 缓存 | +| 6 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 | +| 7 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 | +| 8 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) | +| 9 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 | +| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 | +| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 | +| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 | +| 13 | 无 `next/dynamic` 代码分割 | 全项目 | Admin 首包 300–450KB+ | +| 14 | 19/24 个 `page.tsx` 为 `"use client"` | 各路由 | 无法 RSC 预取,bundle 偏大 | +| 15 | Admin 部分页面用 `useEffect` 而非 React Query | `access-logs.tsx`, `task-executions.tsx` 等 | 无缓存去重,重复请求 | +| 16 | 登录页 OIDC sources 等待 public config | `frontend/components/auth/login-form.tsx` | 多 1 次 RTT 瀑布 | +| 17 | Users 表每行嵌套 3 个 `TooltipProvider` | `frontend/app/(main)/admin/users/page.tsx` | 50+ 行时不必要重渲染 | +| 18 | 缩略图用原生 `` 无 lazy loading | `file-list.tsx`, `file-manager.tsx` | 文件管理页初始解码压力大 | +| 19 | `@/lib/services` barrel 导入 | ~40 个文件 | 单路由 bundle 膨胀 10–30KB | +| 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 预取待做 | | 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ | -| 10 | CAPTCHA 配置快照(一次加载 5 个 key) | 验证码路径 Redis ops ↓ 80% | ⬜ | -| 11 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ | -| 12 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ | -| 13 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ | -| 14 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ | -| 15 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ | +| 10 | 系统配置 Otter RAM 缓存 + 统一失效 | 热路径 `GetByKey` 零 Redis RTT(命中后) | ✅ | +| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ | +| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ | +| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ | +| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ | +| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ | +| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ | ### P2 — 中期架构演进 @@ -370,28 +394,30 @@ if (loading || !user) { | # | 设计 | 位置 | |---|------|------| -| 1 | 系统配置单 key Redis Hash 缓存 | `internal/model/system_configs.go` — `GetByKey` | -| 2 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/storage/storage.go` — `Active()` | -| 3 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` | -| 4 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` | -| 5 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` | -| 6 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/db/` | -| 7 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup | -| 8 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` | -| 9 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` | -| 10 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` | -| 11 | 无 GORM `Preload` 滥用 | 全项目 | -| 12 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` | -| 13 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` | -| 14 | React Compiler 已启用 | `frontend/next.config.ts` | -| 15 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` | -| 16 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` | -| 17 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` | -| 18 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook | -| 19 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` | -| 20 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` | -| 21 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` | -| 22 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` | +| 1 | 系统配置三层缓存 RAM → Redis → DB | `pkg/cache/ram`, `system_config_cache.go`, `GetByKey` | +| 2 | 系统配置统一失效 + 多节点 pub/sub | `InvalidateSystemConfigCache`, `InvalidateAllSystemConfigCaches` | +| 3 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/storage/storage.go` — `Active()` | +| 4 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` | +| 5 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` | +| 6 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` | +| 7 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/db/` | +| 8 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup | +| 9 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` | +| 10 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` | +| 11 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` | +| 12 | 无 GORM `Preload` 滥用 | 全项目 | +| 13 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` | +| 14 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` | +| 15 | React Compiler 已启用 | `frontend/next.config.ts` | +| 16 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` | +| 17 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` | +| 18 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` | +| 19 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook | +| 20 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` | +| 21 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` | +| 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 清理批量化 | | 管理端日常使用 | 大 bundle(认证/日志已优化) | P0 #7 dynamic import | | 存储迁移进行中 | Redis 日志风暴 | P2 #17 | -| 登录高峰 | CAPTCHA 5×Redis + OIDC discovery | P1 #10, #11 | -| 多租户 / 跨域前端 | CORS 配置查询 | P1 CORS 缓存 | +| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC | +| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 | +| 参数热更新 | 多节点 RAM 一致性 | ✅ `system:config_invalidation` pub/sub | | 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 | --- @@ -436,7 +463,8 @@ P0 前端 P1 [x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17 -[ ] CAPTCHA 配置快照 +[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17 +[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17 [ ] OIDC Provider 缓存 [ ] Admin useEffect → React Query 统一 [ ] 状态轮询 visibility 感知 @@ -454,8 +482,12 @@ P1 | 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub | | 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` | | 公共配置列表 | `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/db/migrator/goose/*/202606090001_initial_schema.sql` | 缺失复合索引 | +| 上传索引 | `internal/db/migrator/goose/*202606170001*.sql` | ✅ 复合索引已加 | | 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` | | 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 | | 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch | diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index 0de98cb4..1efc0c6c 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "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/common/response" "github.com/Rain-kl/Wavelet/internal/db" @@ -93,9 +94,7 @@ func CreateSystemConfig(c *gin.Context) { return } - if err := model.InvalidateSystemConfigCache(c.Request.Context(), req.Key); err != nil { - logger.WarnF(c.Request.Context(), "清理系统配置缓存失败: %v", err) - } + invalidateSystemConfigCaches(c.Request.Context(), req.Key) if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil { 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 { 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 { upload.ResetAccessCaches() diff --git a/internal/apps/cap/manager.go b/internal/apps/cap/manager.go index 6abbfabb..df37c380 100644 --- a/internal/apps/cap/manager.go +++ b/internal/apps/cap/manager.go @@ -15,73 +15,47 @@ import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/model" pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" ) const ( - managerDefaultChallengeCount = 1 - managerDefaultChallengeSize = 32 - defaultChallengeDifficulty = 4 - defaultChallengeTTL = 10 * time.Minute - defaultTokenTTL = 20 * time.Minute - redeemTokenIDLength = 8 // 兑换 Token ID 字节长度 - redeemVerTokenLength = 15 // 兑换验证 Token 字节长度 - tokenPartsCount = 2 // 兑换 Token 由两部分组成 - valuePartsCount = 2 // 存储值由 scope 和过期时间组成 + redeemTokenIDLength = 8 // 兑换 Token ID 字节长度 + redeemVerTokenLength = 15 // 兑换验证 Token 字节长度 + tokenPartsCount = 2 // 兑换 Token 由两部分组成 + valuePartsCount = 2 // 存储值由 scope 和过期时间组成 ) -// Config holds settings for the CAPTCHA manager -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 +// Manager orchestrates challenge generation and solution validation. type Manager struct { - conf Config - store pkgcap.Store + secret []byte + store pkgcap.Store } -// NewManager creates a new CAPTCHA Manager -func NewManager(conf Config, 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 - } +// NewManager creates a new CAPTCHA Manager. +func NewManager(secret []byte, store pkgcap.Store) *Manager { return &Manager{ - conf: conf, - store: store, + secret: secret, + store: store, } } -// Generate creates a challenge response +// Generate creates a challenge response. func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) { - c := pkgcap.ChallengeConfig{ - Count: m.getChallengeCount(ctx), - Size: m.getChallengeSize(ctx), - Difficulty: m.getChallengeDifficulty(ctx), - Expires: m.getChallengeTTL(ctx), + settings, err := CurrentSettings(ctx) + if err != nil { + return nil, err } - 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 { Success bool `json:"success"` Token string `json:"token,omitempty"` @@ -89,7 +63,7 @@ type RedeemResponse struct { 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) { sigHex := pkgcap.JwtSigHex(token) if sigHex == "" { @@ -98,21 +72,17 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco nonceKey := "cap:nonce:" + sigHex - // Atomically claim the nonce slot BEFORE verifying solutions. - payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) + payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.secret, scope) 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) nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond if 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) if err != nil { 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 } - // 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) verToken := pkgcap.RandomHex(redeemVerTokenLength) verHashBytes := sha256.Sum256([]byte(verToken)) verHashHex := hex.EncodeToString(verHashBytes[:]) tokenKey := "cap:token:" + id + ":" + verHashHex - tokenTTL := m.getTokenTTL(ctx) - tokenExpires := time.Now().Add(tokenTTL) - - // Value stored is "expiresNano|scope" + tokenExpires := time.Now().Add(settings.TokenTTL) 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 } @@ -162,7 +133,6 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s tokenKey := "cap:token:" + id + ":" + verHashHex - // Atomically retrieve-and-delete val, exists, err := sGetAndDelete(ctx, m.store, tokenKey) if err != nil { 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) 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] @@ -187,13 +157,12 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s } if time.Now().UnixNano() > expNano { - return false, nil // Expired + return false, 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) { if store == nil { return "", false, nil @@ -201,67 +170,19 @@ func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, 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 ( defaultManager *Manager once sync.Once ) -// GetDefaultManager yields the global singleton CAPTCHA manager +// GetDefaultManager yields the global singleton CAPTCHA manager. func GetDefaultManager() *Manager { once.Do(func() { - var secret []byte + secret := []byte("default-captcha-secret-key-at-least-16-bytes") if config.Config != nil && 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 if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil { store = pkgcap.NewRedisStore(db.Redis) @@ -269,14 +190,7 @@ func GetDefaultManager() *Manager { store = pkgcap.NewMemoryStore(1 * time.Minute) } - defaultManager = NewManager(Config{ - Secret: secret, - ChallengeCount: challengeCount, - ChallengeSize: challengeSize, - ChallengeDifficulty: challengeDifficulty, - ChallengeTTL: challengeTTL, - TokenTTL: tokenTTL, - }, store) + defaultManager = NewManager(secret, store) }) return defaultManager -} +} \ No newline at end of file diff --git a/internal/apps/cap/manager_test.go b/internal/apps/cap/manager_test.go index 1b2c20f7..a8b25ac2 100644 --- a/internal/apps/cap/manager_test.go +++ b/internal/apps/cap/manager_test.go @@ -13,151 +13,141 @@ import ( pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" ) -func TestCapFullFlow(t *testing.T) { - secret := []byte("a-very-long-secret-key-at-least-16-bytes") - store := pkgcap.NewMemoryStore(1 * time.Minute) - - manager := NewManager(Config{ - Secret: secret, - ChallengeCount: 3, // small count for fast test +func installTestManagerSettings(t *testing.T) func() { + t.Helper() + return InstallTestRuntimeSettings(RuntimeSettings{ + ChallengeCount: 3, ChallengeSize: 32, - ChallengeDifficulty: 3, // small difficulty for fast test + ChallengeDifficulty: 3, ChallengeTTL: 5 * 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" ctx := context.Background() resp, err := manager.Generate(ctx, scope) if err != nil { - t.Fatalf("Generate failed: %v", err) + t.Fatalf("Generate() error = %v", err) } 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) - // Redeem redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope) if err != nil { - t.Fatalf("Redeem failed: %v", err) + t.Fatalf("Redeem() error = %v", err) } if !redeemResp.Success { - t.Fatalf("Redeem returned success=false: %s", redeemResp.Error) + t.Fatalf("Redeem().Success = false, error = %s", redeemResp.Error) } 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) if err != nil { - t.Fatalf("VerifyToken failed: %v", err) + t.Fatalf("VerifyToken() error = %v", err) } 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) if err != nil { - t.Fatalf("VerifyToken second call failed: %v", err) + t.Fatalf("VerifyToken() second call error = %v", err) } 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) { 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(Config{ - Secret: secret, - ChallengeCount: 1, - ChallengeSize: 32, - ChallengeDifficulty: 3, - ChallengeTTL: 30 * time.Second, - TokenTTL: 30 * time.Second, - }, store) + manager := NewManager(secret, store) ctx := context.Background() resp, err := manager.Generate(ctx, "login") 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) - 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 ( wg sync.WaitGroup success atomic.Int32 barrier = make(chan struct{}) ) - for i := 0; i < goroutines; i++ { + for range goroutines { wg.Add(1) go func() { defer wg.Done() <-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 { success.Add(1) } @@ -166,7 +156,7 @@ func TestVerifyTokenConcurrentRace(t *testing.T) { close(barrier) wg.Wait() - if n := success.Load(); n != 1 { - t.Fatalf("Expected exactly 1 successful VerifyToken, got %d", n) + if got := success.Load(); got != 1 { + t.Fatalf("successful VerifyToken count = %d, want %d", got, 1) } -} +} \ No newline at end of file diff --git a/internal/apps/cap/middleware.go b/internal/apps/cap/middleware.go index 6260b67c..dfe97f78 100644 --- a/internal/apps/cap/middleware.go +++ b/internal/apps/cap/middleware.go @@ -12,10 +12,9 @@ import ( ) // 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, enabledFunc func() bool) gin.HandlerFunc { +func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { return func(c *gin.Context) { - if enabledFunc != nil && !enabledFunc() { + if !ProtectionEnabled(c.Request.Context()) { c.Next() return } @@ -34,4 +33,4 @@ func VerifyMiddleware(mgr *Manager, scope string, enabledFunc func() bool) gin.H c.Next() } -} +} \ No newline at end of file diff --git a/internal/apps/cap/routers_test.go b/internal/apps/cap/routers_test.go index 956059ec..53798674 100644 --- a/internal/apps/cap/routers_test.go +++ b/internal/apps/cap/routers_test.go @@ -3,20 +3,21 @@ package cap -import ("bytes" +import ( + "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "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/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) { sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t) @@ -32,14 +33,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { capGroup.POST("/redeem", Redeem) } - // Login endpoint with CAPTCHA middleware - 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) { + r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login"), func(c *gin.Context) { 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()) } - // 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 if err != nil { t.Fatalf("failed to enable cap_login_enabled in DB: %v", err) } - // Update cache - var sysCfg model.SystemConfig - sqliteDB.Where("key = ?", model.ConfigKeyCapLoginEnabled).First(&sysCfg) - _ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &sysCfg) + if err := model.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil { + t.Fatalf("InvalidateSystemConfigCache() error = %v", err) + } + InvalidateRuntimeSettings() // 4. Test login with CAPTCHA enabled but no header (should be blocked) w = httptest.NewRecorder() diff --git a/internal/apps/cap/runtime_settings.go b/internal/apps/cap/runtime_settings.go new file mode 100644 index 00000000..4b920f7d --- /dev/null +++ b/internal/apps/cap/runtime_settings.go @@ -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() + } + } + }() +} \ No newline at end of file diff --git a/internal/apps/cap/runtime_settings_test.go b/internal/apps/cap/runtime_settings_test.go new file mode 100644 index 00000000..fd7d8d3b --- /dev/null +++ b/internal/apps/cap/runtime_settings_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/apps/cap/testhelper_hook.go b/internal/apps/cap/testhelper_hook.go new file mode 100644 index 00000000..d5de27be --- /dev/null +++ b/internal/apps/cap/testhelper_hook.go @@ -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) +} \ No newline at end of file diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 40554378..61775469 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -115,6 +115,47 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { 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. func InvalidateVisibleSystemConfigsCache(ctx context.Context) error { if db.Redis == nil { diff --git a/internal/router/middlewares_test.go b/internal/router/middlewares_test.go index 6454d1bc..7352ee0d 100644 --- a/internal/router/middlewares_test.go +++ b/internal/router/middlewares_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/gin-gonic/gin" @@ -21,9 +20,10 @@ func TestCORSMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) - // Helper to clear config cache 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) { diff --git a/internal/router/v1/user.go b/internal/router/v1/user.go index d6ccd33e..662a8b23 100644 --- a/internal/router/v1/user.go +++ b/internal/router/v1/user.go @@ -5,14 +5,11 @@ package v1 import ( - "context" - capApp "github.com/Rain-kl/Wavelet/internal/apps/cap" publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/apps/upload" "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/gin-gonic/gin" ) @@ -64,27 +61,9 @@ func registerOAuthRoutes(apiV1Router *gin.RouterGroup) { func registerUserRoutes(apiV1Router *gin.RouterGroup) { userRouter := apiV1Router.Group("/user") { - userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login", func() bool { - enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled) - if err != nil { - 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.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login"), user.Login) + userRouter.POST("/register", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "register"), user.Register) + userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "send_email_code"), user.SendEmailCode) userRouter.GET("/logout", user.Logout) userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword) diff --git a/internal/testhelper/cleanup.go b/internal/testhelper/cleanup.go new file mode 100644 index 00000000..fd18635e --- /dev/null +++ b/internal/testhelper/cleanup.go @@ -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() + } +} \ No newline at end of file diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index a5651e73..23b4092f 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -75,6 +75,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) // Cleanup function cleanup := func() { + runExtraCleanups() model.ResetSystemConfigRAMCacheForTest() _ = redisClient.Close() mr.Close()