diff --git a/Agents.md b/Agents.md index fc832340..2bc86e41 100644 --- a/Agents.md +++ b/Agents.md @@ -396,6 +396,58 @@ func ListUsers(c *gin.Context) { | 禁止行为 | 说明 | |----------|------| | **禁止删除 `node_modules` 目录** | `node_modules` 为前端依赖安装目录,删除会导致项目无法运行。如需重新安装依赖,使用 `pnpm install` 覆盖更新即可,严禁执行 `rm -rf node_modules`。 | +| **`internal/util/` 下禁止引用框架包** | `util/` 及其子包(如 `util/cap`)定位为**纯工具层**,不得 `import` 任何 HTTP / ORM / 框架包,包括但不限于 `github.com/gin-gonic/gin`、`gorm.io/gorm`、`github.com/gin-contrib/sessions`。违反此约束会导致工具层与框架产生耦合,无法独立测试。详见 **6.11** 的建议方案。 | + +### 6.11 `util/` 包依赖约束与建议方案 + +#### 约束范围 + +`internal/util/` 及其全部子包(如 `util/cap`、`util/crypto` 等)只允许引用: + +- Go 标准库(`context`、`crypto`、`encoding`、`net/http` 原生包等) +- 项目内同级别的纯工具包(`internal/config`、`internal/db`、`internal/model` 等无框架依赖的包) +- 与框架无关的第三方库(如 `github.com/redis/go-redis`、`github.com/shopspring/decimal` 等) + +**严禁引用**:`github.com/gin-gonic/gin`、`gorm.io/gorm`、`github.com/gin-contrib/sessions` 及任何 HTTP 框架 / Web 中间件相关包。 + +#### 常见误区与建议方案 + +| 误区 | 建议方案 | +|------|----------| +| 在 `util/` 中写 `gin.HandlerFunc` 形式的中间件 | 将中间件移至对应的 `apps//middleware.go`,通过**函数参数**接收 `util/` 层的核心对象(如 `*cap.Manager`) | +| 在 `util/` 中通过 `*gin.Context` 写响应 | 只在 `util/` 中计算/校验逻辑并返回 `(result, error)`,由 `apps/` 层的 Handler 负责调用 `c.AbortWithStatusJSON` 写响应 | +| 在 `util/` 中使用 `gorm.DB` 直接查询 | 将数据库查询封装在 `internal/model/` 层方法中,`util/` 只接收已查出的数据结构 | + +#### 正确示例 + +```go +// ✅ internal/util/cap/manager.go — 纯逻辑,无框架依赖 +func (m *Manager) VerifyToken(ctx context.Context, token, scope string) (bool, error) { + // 只依赖 context、标准库、redis client + ... +} + +// ✅ internal/apps/cap/middleware.go — 框架胶水层,持有 gin 依赖 +func VerifyMiddleware(mgr *caputil.Manager, scope string, enabledFunc func() bool) gin.HandlerFunc { + return func(c *gin.Context) { + valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) // 调用纯逻辑 + if err != nil || !valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败")) + return + } + c.Next() + } +} +``` + +#### 错误示例(禁止) + +```go +// ❌ internal/util/cap/middleware.go — util/ 层不应出现 gin +import "github.com/gin-gonic/gin" + +func (m *Manager) VerifyMiddleware(...) gin.HandlerFunc { ... } +``` --- diff --git a/config.example.yaml b/config.example.yaml index 03b70c0f..c45c3787 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -128,3 +128,4 @@ s3: local_cache: enabled: false cache_dir: "./s3_cache" + diff --git a/docs/docs.go b/docs/docs.go index 02938521..bd8ac336 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,6 +22,91 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/cap/challenge": { + "post": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + } + }, + "/api/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "校验人机验证解答", + "parameters": [ + { + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + } + }, "/api/v1/admin/auth-sources": { "get": { "security": [ @@ -367,6 +452,55 @@ const docTemplate = `{ } } }, + "/api/v1/admin/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统状态信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.SystemStatusResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { "security": [ @@ -2602,6 +2736,78 @@ const docTemplate = `{ } } }, + "cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" + }, + "solutions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "token": { + "type": "string" + } + } + }, "model.AccessToken": { "type": "object", "properties": { @@ -3002,6 +3208,95 @@ const docTemplate = `{ } } }, + "status.SystemStatusResponse": { + "type": "object", + "properties": { + "alloc": { + "type": "string" + }, + "buck_hash_sys": { + "type": "string" + }, + "frees": { + "type": "integer" + }, + "gc_sys": { + "type": "string" + }, + "heap_alloc": { + "type": "string" + }, + "heap_idle": { + "type": "string" + }, + "heap_inuse": { + "type": "string" + }, + "heap_objects": { + "type": "integer" + }, + "heap_released": { + "type": "string" + }, + "heap_sys": { + "type": "string" + }, + "last_gc_time": { + "type": "string" + }, + "last_pause": { + "type": "string" + }, + "lookups": { + "type": "integer" + }, + "mallocs": { + "type": "integer" + }, + "mcache_inuse": { + "type": "string" + }, + "mcache_sys": { + "type": "string" + }, + "mspan_inuse": { + "type": "string" + }, + "mspan_sys": { + "type": "string" + }, + "next_gc": { + "type": "string" + }, + "num_gc": { + "type": "integer" + }, + "num_goroutine": { + "type": "integer" + }, + "other_sys": { + "type": "string" + }, + "pause_total_ns": { + "type": "string" + }, + "stack_inuse": { + "type": "string" + }, + "stack_sys": { + "type": "string" + }, + "sys": { + "type": "string" + }, + "total_alloc": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index ab433e34..a133d46b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -15,6 +15,91 @@ }, "basePath": "/", "paths": { + "/api/cap/challenge": { + "post": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + } + }, + "/api/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "校验人机验证解答", + "parameters": [ + { + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + } + }, "/api/v1/admin/auth-sources": { "get": { "security": [ @@ -360,6 +445,55 @@ } } }, + "/api/v1/admin/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统状态信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.SystemStatusResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { "security": [ @@ -2595,6 +2729,78 @@ } } }, + "cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" + }, + "solutions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "token": { + "type": "string" + } + } + }, "model.AccessToken": { "type": "object", "properties": { @@ -2995,6 +3201,95 @@ } } }, + "status.SystemStatusResponse": { + "type": "object", + "properties": { + "alloc": { + "type": "string" + }, + "buck_hash_sys": { + "type": "string" + }, + "frees": { + "type": "integer" + }, + "gc_sys": { + "type": "string" + }, + "heap_alloc": { + "type": "string" + }, + "heap_idle": { + "type": "string" + }, + "heap_inuse": { + "type": "string" + }, + "heap_objects": { + "type": "integer" + }, + "heap_released": { + "type": "string" + }, + "heap_sys": { + "type": "string" + }, + "last_gc_time": { + "type": "string" + }, + "last_pause": { + "type": "string" + }, + "lookups": { + "type": "integer" + }, + "mallocs": { + "type": "integer" + }, + "mcache_inuse": { + "type": "string" + }, + "mcache_sys": { + "type": "string" + }, + "mspan_inuse": { + "type": "string" + }, + "mspan_sys": { + "type": "string" + }, + "next_gc": { + "type": "string" + }, + "num_gc": { + "type": "integer" + }, + "num_goroutine": { + "type": "integer" + }, + "other_sys": { + "type": "string" + }, + "pause_total_ns": { + "type": "string" + }, + "stack_inuse": { + "type": "string" + }, + "stack_sys": { + "type": "string" + }, + "sys": { + "type": "string" + }, + "total_alloc": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4f715890..a4fa55c0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -26,6 +26,53 @@ definitions: is_active: type: boolean type: object + cap.ChallengeResponse: + properties: + challenge: + properties: + c: + type: integer + d: + type: integer + s: + type: integer + type: object + expires: + description: ms timestamp + type: integer + token: + type: string + type: object + cap.RedeemResponse: + properties: + error: + type: string + expires: + type: integer + success: + type: boolean + token: + type: string + type: object + cap.challengeRequest: + properties: + scope: + type: string + type: object + cap.redeemRequest: + properties: + scope: + type: string + solutions: + items: + type: integer + type: array + token: + type: string + required: + - solutions + - token + type: object model.AccessToken: properties: created_at: @@ -297,6 +344,65 @@ definitions: user: $ref: '#/definitions/oauth.BasicUserInfo' type: object + status.SystemStatusResponse: + properties: + alloc: + type: string + buck_hash_sys: + type: string + frees: + type: integer + gc_sys: + type: string + heap_alloc: + type: string + heap_idle: + type: string + heap_inuse: + type: string + heap_objects: + type: integer + heap_released: + type: string + heap_sys: + type: string + last_gc_time: + type: string + last_pause: + type: string + lookups: + type: integer + mallocs: + type: integer + mcache_inuse: + type: string + mcache_sys: + type: string + mspan_inuse: + type: string + mspan_sys: + type: string + next_gc: + type: string + num_gc: + type: integer + num_goroutine: + type: integer + other_sys: + type: string + pause_total_ns: + type: string + stack_inuse: + type: string + stack_sys: + type: string + sys: + type: string + total_alloc: + type: string + uptime: + type: string + type: object system_config.CreateSystemConfigRequest: properties: description: @@ -496,6 +602,61 @@ info: title: LINUX DO Credit version: 1.0.0 paths: + /api/cap/challenge: + post: + consumes: + - application/json + description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 + parameters: + - description: 可选范围限制参数 + in: body + name: request + schema: + $ref: '#/definitions/cap.challengeRequest' + produces: + - application/json + responses: + "200": + description: 成功返回 PoW 难题 + schema: + $ref: '#/definitions/cap.ChallengeResponse' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/cap.RedeemResponse' + summary: 生成人机验证难题 + tags: + - cap + /api/cap/redeem: + post: + consumes: + - application/json + description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 + parameters: + - description: 难题 Token 与解答 solutions 数组 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cap.redeemRequest' + produces: + - application/json + responses: + "200": + description: 核销成功,返回 X-Cap-Token + schema: + $ref: '#/definitions/cap.RedeemResponse' + "400": + description: 参数错误或核销失败 + schema: + $ref: '#/definitions/cap.RedeemResponse' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/cap.RedeemResponse' + summary: 校验人机验证解答 + tags: + - cap /api/v1/admin/auth-sources: get: description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 @@ -703,6 +864,34 @@ paths: summary: 切换认证源启用状态 tags: - admin + /api/v1/admin/status: + get: + description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/status.SystemStatusResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取系统状态信息 + tags: + - admin /api/v1/admin/system-configs: get: description: 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 diff --git a/frontend/components/auth/cap-widget.tsx b/frontend/components/auth/cap-widget.tsx new file mode 100644 index 00000000..7a727ff8 --- /dev/null +++ b/frontend/components/auth/cap-widget.tsx @@ -0,0 +1,110 @@ +'use client' + +import {useEffect, useRef, useState} from 'react' +import {CheckCircle2, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion} from 'lucide-react' +import {getCapToken} from '@/lib/cap-solver' + +type CapStatus = 'idle' | 'solving' | 'solved' | 'error' + +interface CapWidgetProps { + /** Called with the one-time token once the challenge is solved */ + onToken: (token: string) => void + /** Called when the challenge fails or encounters an error */ + onError?: (err: Error) => void + /** Whether to auto-start solving when the component mounts. Defaults to true. */ + autoStart?: boolean + /** PoW scope sent to the backend */ + scope?: string +} + +/** + * Cap 人机验证小部件 + * + * autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。 + * autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。 + */ +export function CapWidget({ onToken, onError, autoStart = true, scope = 'login' }: CapWidgetProps) { + const [status, setStatus] = useState('idle') + const [errorMsg, setErrorMsg] = useState('') + const solving = useRef(false) + + const solve = async () => { + if (solving.current) return + solving.current = true + setStatus('solving') + setErrorMsg('') + try { + const token = await getCapToken(scope) + setStatus('solved') + onToken(token) + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + setStatus('error') + setErrorMsg(error.message) + onError?.(error) + } finally { + solving.current = false + } + } + + // Auto-start on mount (only when autoStart is enabled) + useEffect(() => { + if (autoStart) { + void solve() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {/* idle + autoStart=false: 手动触发按钮 */} + {status === 'idle' && !autoStart && ( + + )} + + {/* idle + autoStart=true: 等待自动开始(极短暂状态) */} + {status === 'idle' && autoStart && ( + <> + + 等待人机验证… + + )} + + {status === 'solving' && ( + <> + + 正在完成人机验证… + + )} + + {status === 'solved' && ( + <> + + 人机验证通过 + + + )} + + {status === 'error' && ( + + )} +
+ ) +} diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 92df2bcd..81002140 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -1,6 +1,6 @@ "use client" -import {useMemo, useState} from "react" +import {useMemo, useRef, useState} from "react" import {useMutation, useQuery} from "@tanstack/react-query" import {useRouter, useSearchParams} from "next/navigation" import {KeyRound, ShieldCheck, UserPlus} from "lucide-react" @@ -13,7 +13,9 @@ import {Separator} from "@/components/ui/separator" import {Spinner} from "@/components/ui/spinner" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import {Card, CardContent} from "@/components/ui/card" +import {CapWidget} from "@/components/auth/cap-widget" import services from "@/lib/services" +import type {LoginRequest, RegisterRequest} from "@/lib/services/auth/types" function getRedirectTarget(searchParams: ReturnType) { const callbackUrl = searchParams.get("callbackUrl") @@ -44,6 +46,12 @@ export function LoginForm() { const [nickname, setNickname] = useState("") const [errorMessage, setErrorMessage] = useState("") + // Cap token management — ref to hold latest token without triggering re-render + const capTokenRef = useRef(null) + const [capReady, setCapReady] = useState(false) + const [capError, setCapError] = useState(false) + const [capResetKey, setCapResetKey] = useState(0) + const publicConfigQuery = useQuery({ queryKey: ["public-config"], queryFn: () => services.config.getPublicConfig(), @@ -60,19 +68,36 @@ export function LoginForm() { [searchParams], ) + const capEnabled = publicConfigQuery.data?.cap_login_enabled ?? false + const capAutoSolve = publicConfigQuery.data?.cap_auto_solve ?? true + const loginMutation = useMutation({ - mutationFn: (req: any) => services.auth.login(req), + mutationFn: (req: LoginRequest) => { + const headers: Record = {} + if (capEnabled && capTokenRef.current) { + headers["X-Cap-Token"] = capTokenRef.current + // Consume the token — next login attempt will need a new one + capTokenRef.current = null + setCapReady(false) + } + return services.auth.login(req, Object.keys(headers).length ? headers : undefined) + }, onSuccess: (user) => { setUser(user) router.replace(redirectTarget) }, onError: (error: Error) => { - setErrorMessage(error.message || "登录失败,请重试") + toast.error(error.message || "登录失败,请重试") + if (capEnabled) { + capTokenRef.current = null + setCapReady(false) + setCapResetKey((key) => key + 1) + } }, }) const registerMutation = useMutation({ - mutationFn: (req: any) => services.auth.register(req), + mutationFn: (req: RegisterRequest) => services.auth.register(req), onSuccess: (user) => { setUser(user) router.replace(redirectTarget) @@ -84,8 +109,23 @@ export function LoginForm() { const handlePasswordLogin = () => { setErrorMessage("") + const trimmedUsername = username.trim() + if (!trimmedUsername || !password) { + toast.error("账号或密码未填写完整", { + description: "请先输入账号和密码后再登录", + }) + return + } + if (capEnabled && !capReady) { + toast.error( + capAutoSolve + ? "人机验证尚未完成,请稍候…" + : "请先点击「开始验证」完成人机验证", + ) + return + } loginMutation.mutate({ - username: username.trim(), + username: trimmedUsername, password, }) } @@ -110,6 +150,18 @@ export function LoginForm() { } } + const handleCapToken = (token: string) => { + capTokenRef.current = token + setCapReady(true) + setCapError(false) + } + + const handleCapError = () => { + capTokenRef.current = null + setCapReady(false) + setCapError(true) + } + const registrationEnabled = (publicConfigQuery.data?.registration_enabled ?? true) && (publicConfigQuery.data?.password_register_enabled ?? true) @@ -118,6 +170,16 @@ export function LoginForm() { const authSources = authSourcesQuery.data ?? [] + // Login button disabled when: + // - password login is off, OR + // - login mutation is pending, OR + // - cap is enabled AND auto-solve mode AND not yet solved (and not in error state) + // When autoStart=false (manual mode), idle means the user hasn't clicked yet — don't block. + const loginDisabled = + !passwordLoginEnabled || + loginMutation.isPending || + (capEnabled && capAutoSolve && !capReady && !capError) + return ( @@ -143,6 +205,7 @@ export function LoginForm() { onChange={(e) => setUsername(e.target.value)} placeholder="用户名" autoComplete="username" + onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} /> e.key === "Enter" && handlePasswordLogin()} /> - {errorMessage ? ( -
- {errorMessage} -
- ) : null} + {/* Cap 人机验证 */} + {capEnabled && ( + + )} + + + + {status && ( + <> + {/* 主网格排版 */} +
+ + {/* 1. 服务概览 */} + + +
+ 服务概览 + 服务的基础生命指标 +
+ + + +
+ + + + + +
+ + {/* 2. 内存统计 */} + + +
+ 内存统计 + 主内存消耗概览 +
+ + + +
+ +
+ + + + +
+ {/* 物理内存水位比例 */} +
+
+ 当前使用率 (Alloc / Sys) + {getMemoryUsagePercent()}% +
+ +
+
+
+ + {/* 3. 堆/栈详情 */} + + +
+ 堆/栈详情 + 运行时堆栈内存空间细节 +
+ + + +
+ + + + + + + + + +
+ + {/* 4. 底层组件与结构体 */} + + +
+ 底层及结构体内存 + 运行时底层管理结构体开销 +
+ + + +
+ + + + + + + + + +
+ + {/* 5. 垃圾回收与分配计数 */} + + +
+ 垃圾回收与分配计数 + GC 历史数据与分配频次 +
+ + + +
+ +
+
+ + + + +
+
+ + + +
+
+
+
+ +
+ + )} + + ) +} diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index 13009ad9..d06761f6 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -3,17 +3,16 @@ import {useEffect, useMemo, useState} from "react" import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" import { - CalendarClock, Fingerprint, Globe, Info, Loader2, Lock, - Monitor, Pencil, Plus, Server, Settings, + Shield, Trash2, UserPlus } from "lucide-react" @@ -25,11 +24,14 @@ import {Button} from "@/components/ui/button" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Switch} from "@/components/ui/switch" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" +import {Input} from "@/components/ui/input" +import {Label} from "@/components/ui/label" import {useAuth} from "@/components/providers/auth-provider" import {AuthSourceModal} from "@/components/common/settings/auth-source-modal" import {AdminService, apiConfig} from "@/lib/services" import type {AuthSource, SystemConfig} from "@/lib/services/admin" import {toast} from "sonner" +import {SystemStatusManager} from "@/components/common/admin/status" const SECURITY_KEYS = [ { @@ -76,10 +78,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { ) } -function formatBooleanConfig(config?: SystemConfig) { - if (!config) return "未配置" - return config.value === "true" ? "启用" : "禁用" -} + export function SecurityMain() { const queryClient = useQueryClient() @@ -87,13 +86,13 @@ export function SecurityMain() { const router = useRouter() const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false) const [selectedSource, setSelectedSource] = useState(null) - const [runtimeInfo, setRuntimeInfo] = useState({ - language: "-", - platform: "-", - timezone: "-", - viewport: "-", - userAgent: "-", - }) + + const [capCount, setCapCount] = useState("") + const [capDifficulty, setCapDifficulty] = useState("") + const [capSize, setCapSize] = useState("") + const [capTTL, setCapTTL] = useState("") + const [capTokenTTL, setCapTokenTTL] = useState("") + const [capAutoSolve, setCapAutoSolve] = useState(true) const systemConfigsQuery = useQuery({ queryKey: ["admin", "system-configs"], @@ -119,17 +118,19 @@ export function SecurityMain() { }, [user, loading, router]) useEffect(() => { - setRuntimeInfo({ - language: navigator.language || "-", - platform: navigator.platform || "-", - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "-", - viewport: `${window.innerWidth} x ${window.innerHeight}`, - userAgent: navigator.userAgent || "-", - }) - }, []) + if (systemConfigsQuery.data) { + const cfgMap = systemConfigMap(systemConfigsQuery.data) + setCapCount(cfgMap["cap_challenge_count"]?.value || "1") + setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4") + setCapSize(cfgMap["cap_challenge_size"]?.value || "32") + setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600") + setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200") + setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false") + } + }, [systemConfigsQuery.data]) const updateConfigMutation = useMutation({ - mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => { + mutationFn: async ({ key, value }: { key: string; value: boolean }) => { const config = configs[key] if (!config) { throw new Error(`缺少配置项: ${key}`) @@ -176,10 +177,43 @@ export function SecurityMain() { }, }) - const handleToggle = (key: SecurityKey, checked: boolean) => { + const handleToggle = (key: string, checked: boolean) => { updateConfigMutation.mutate({ key, value: checked }) } + const saveCapMutation = useMutation({ + mutationFn: async () => { + const updates = [ + { key: "cap_challenge_count", value: capCount }, + { key: "cap_challenge_difficulty", value: capDifficulty }, + { key: "cap_challenge_size", value: capSize }, + { key: "cap_challenge_ttl_seconds", value: capTTL }, + { key: "cap_token_ttl_seconds", value: capTokenTTL }, + { key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" }, + ] + + for (const update of updates) { + const currentCfg = configs[update.key] + await AdminService.updateSystemConfig(update.key, { + value: update.value, + description: currentCfg?.description || "", + }) + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) + toast.success("人机验证配置已成功保存") + }, + onError: (error: Error) => { + toast.error(error.message || "保存配置失败") + }, + }) + + const handleCapSave = (e: React.FormEvent) => { + e.preventDefault() + saveCapMutation.mutate() + } + if (loading || !user || !user.is_admin) { return (
@@ -198,7 +232,7 @@ export function SecurityMain() { - 系统安全设置 + 安全设置 运营设置 @@ -206,6 +240,9 @@ export function SecurityMain() { 系统设置 + + 系统状态 + 其他设置 @@ -376,10 +413,138 @@ export function SecurityMain() { )} + + {/* 人机验证配置 (Cap CAPTCHA) */} + + +
+
+ +
+
+ 人机验证配置 (Cap CAPTCHA) + 配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击 +
+
+ handleToggle("cap_login_enabled", checked)} + /> +
+ + {/* 自动开始计算 Switch */} +
+
+

打开页面后自动开始计算

+
+ +
+
+
+
+ + setCapCount(e.target.value)} + placeholder="50" + className="bg-card border-dashed text-xs" + /> +

客户端需求解的难题总数。默认 1,推荐 1 至 5

+
+ +
+ + setCapDifficulty(e.target.value)} + placeholder="4" + className="bg-card border-dashed text-xs" + /> +

PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4

+
+ +
+ + setCapSize(e.target.value)} + placeholder="32" + className="bg-card border-dashed text-xs" + /> +

难题盐值混淆字符长度。默认 32

+
+ +
+ + setCapTTL(e.target.value)} + placeholder="600" + className="bg-card border-dashed text-xs" + /> +

难题有效期限。默认 600 秒 (10 分钟)

+
+ +
+ + setCapTokenTTL(e.target.value)} + placeholder="1200" + className="bg-card border-dashed text-xs" + /> +

PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)

+
+
+ +
+ +
+
+
+
+ + +
@@ -424,48 +589,6 @@ export function SecurityMain() { - - - -
-
- -
-
- 运行环境 - 当前浏览器会话的本地运行信息 -
-
-
- - - - - - - -
- - - -
-
- -
-
- 安全配置概览 - 当前系统登录与注册开关状态 -
-
-
- - - - - - - -
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index f92b2dcf..fa079528 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -67,16 +67,17 @@ const data = { { title: "首页", url: "/home", icon: Home }, ], admin: [ - { title: "系统设置", url: "/admin/settings", icon: Settings }, - { title: "系统配置", url: "/admin/system", icon: ShieldCheck }, { title: "用户管理", url: "/admin/users", icon: UserRound }, - { title: "任务管理", url: "/admin/tasks", icon: Layers }, { title: "文件管理", url: "/admin/files", icon: FolderOpen }, + { title: "任务管理", url: "/admin/tasks", icon: Layers }, + { title: "系统配置", url: "/admin/system", icon: ShieldCheck }, + { title: "系统设置", url: "/admin/settings", icon: Settings }, + ], document: [ + { title: "组件库", url: "/components", icon: Palette }, { title: "接口文档", url: "/docs/api", icon: CreditCard, external: true }, { title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true }, - { title: "组件库", url: "/components", icon: Palette }, ], } diff --git a/frontend/lib/cap-solver.ts b/frontend/lib/cap-solver.ts new file mode 100644 index 00000000..d911ea88 --- /dev/null +++ b/frontend/lib/cap-solver.ts @@ -0,0 +1,183 @@ +/** + * Cap PoW (Proof-of-Work) 人机验证前端实现 + * 与后端 internal/util/cap 算法完全对应 + * + * 求解在 Web Worker 中执行,不阻塞主线程 UI。 + */ + +// ——— Challenge / Redeem API types ——— + +export interface ChallengeResponse { + challenge: { c: number; s: number; d: number }; + token: string; + expires: number; +} + +export interface RedeemResponse { + success: boolean; + token?: string; + expires?: number; + error?: string; +} + +// ——— Worker 代码(内联 Blob,避免独立文件的打包配置问题)——— +// +// 算法与 Go 后端 internal/util/cap/cap.go + prng.go 完全对应: +// · FNV-1a 32-bit (Math.imul 保证 32-bit 截断) +// · xorshift32 PRNG → hex 字符串 +// · SubtleCrypto SHA-256 校验答案 + +const WORKER_SOURCE = /* js */ ` +// FNV-1a 32-bit +function fnv1a(str) { + let h = 2166136261 >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h; +} +function fnv1aResume(state, str) { + let h = state >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h; +} + +// xorshift32 PRNG → hex string +function prngFromHash(seed, len) { + let s = seed >>> 0, r = ''; + while (r.length < len) { + s ^= (s << 13) >>> 0; + s ^= (s >>> 17) >>> 0; + s ^= (s << 5) >>> 0; + s = s >>> 0; + r += s.toString(16).padStart(8, '0'); + } + return r.slice(0, len); +} + +// SHA-256 via SubtleCrypto (available in workers) +async function sha256Hex(input) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +// Solve one puzzle +async function solvePuzzle(token, index, size, difficulty) { + const tFnv = fnv1a(token); + const saltSeed = fnv1aResume(tFnv, String(index + 1)); + const targetSeed = fnv1aResume(saltSeed, 'd'); + const salt = prngFromHash(saltSeed, size); + const target = prngFromHash(targetSeed, difficulty); + + for (let nonce = 0; nonce < 10_000_000; nonce++) { + if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce; + } + throw new Error('无法在合理范围内求解第 ' + (index + 1) + ' 个难题'); +} + +// Entry point — receive task from main thread +self.onmessage = async ({ data }) => { + const { token, count, size, difficulty } = data; + try { + const t0 = performance.now(); + const solutions = []; + for (let i = 0; i < count; i++) { + solutions.push(await solvePuzzle(token, i, size, difficulty)); + } + const elapsed = ((performance.now() - t0) / 1000).toFixed(3); + self.postMessage({ type: 'done', solutions, elapsed }); + } catch (err) { + self.postMessage({ type: 'error', message: err.message }); + } +}; +`; + +// ——— 在 Web Worker 中求解,返回 Promise ——— + +function solveInWorker( + token: string, + count: number, + size: number, + difficulty: number, +): Promise<{ solutions: number[]; elapsed: string }> { + return new Promise((resolve, reject) => { + const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + + worker.onmessage = (e) => { + URL.revokeObjectURL(url); + worker.terminate(); + if (e.data.type === 'done') { + resolve({ solutions: e.data.solutions, elapsed: e.data.elapsed }); + } else { + reject(new Error(e.data.message)); + } + }; + + worker.onerror = (err) => { + URL.revokeObjectURL(url); + worker.terminate(); + reject(new Error(err.message || 'Worker 执行失败')); + }; + + worker.postMessage({ token, count, size, difficulty }); + }); +} + +// ——— Full Cap flow: get challenge → solve (Worker) → redeem → return X-Cap-Token ——— + +export async function getCapToken(scope = 'login'): Promise { + // 1. 获取难题 + const challengeRes = await fetch('/api/cap/challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope }), + }); + if (!challengeRes.ok) { + throw new Error('获取人机验证难题失败'); + } + const challenge: ChallengeResponse = await challengeRes.json(); + const { c: count, s: size, d: difficulty } = challenge.challenge; + + console.groupCollapsed('[Cap] 人机验证 PoW 求解开始'); + console.log(` 难题数量 (count) : ${count}`); + console.log(` 验证难度 (difficulty): ${difficulty}`); + console.log(` 盐值长度 (size) : ${size}`); + console.groupEnd(); + + // 2. 在 Worker 中求解(不阻塞主线程) + const t0 = performance.now(); + const { solutions, elapsed } = await solveInWorker( + challenge.token, + count, + size, + difficulty, + ); + const wallTime = ((performance.now() - t0) / 1000).toFixed(3); + + console.groupCollapsed('[Cap] 人机验证 PoW 求解完成'); + console.log(` 难题数量 (count) : ${count}`); + console.log(` 验证难度 (difficulty): ${difficulty}`); + console.log(` Worker 耗时 : ${elapsed}s`); + console.log(` 总耗时(含通信) : ${wallTime}s`); + console.log(` Solutions :`, solutions); + console.groupEnd(); + + // 3. 提交答案兑换一次性凭证 + const redeemRes = await fetch('/api/cap/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: challenge.token, solutions, scope }), + }); + const redeemData: RedeemResponse = await redeemRes.json(); + if (!redeemData.success || !redeemData.token) { + throw new Error(redeemData.error || '人机验证失败'); + } + + return redeemData.token; +} diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index 4a091437..2347d6df 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -11,6 +11,7 @@ import type { ListUsersRequest, ListUsersResponse, SystemConfig, + SystemStatus, TaskExecution, TaskMeta, TaskTypeResponse, @@ -318,4 +319,14 @@ export class AdminService extends BaseService { static async createUser(request: CreateUserRequest): Promise { return this.post('/users', request); } + + /** + * 获取系统状态 + * @returns 系统状态指标数据 + * @throws {UnauthorizedError} 当未登录时 + * @throws {ForbiddenError} 当无管理员权限时 + */ + static async getSystemStatus(): Promise { + return this.get('/status'); + } } diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index ddc2cac8..b2166616 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -38,4 +38,5 @@ export type { ListUsersRequest, ListUsersResponse, UpdateUserStatusRequest, + SystemStatus, } from './types'; diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index e3ab3de8..f5db3418 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -254,3 +254,37 @@ export interface AuthSourceRequest { export interface ToggleAuthSourceRequest { is_active: boolean; } + +/** + * 系统状态信息 + */ +export interface SystemStatus { + uptime: string; + num_goroutine: number; + alloc: string; + total_alloc: string; + sys: string; + lookups: number; + mallocs: number; + frees: number; + heap_alloc: string; + heap_sys: string; + heap_idle: string; + heap_inuse: string; + heap_released: string; + heap_objects: number; + stack_inuse: string; + stack_sys: string; + mspan_inuse: string; + mspan_sys: string; + mcache_inuse: string; + mcache_sys: string; + buck_hash_sys: string; + gc_sys: string; + other_sys: string; + next_gc: string; + last_gc_time: string; + pause_total_ns: string; + last_pause: string; + num_gc: number; +} diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index f87127a8..6530955c 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -103,8 +103,8 @@ export class AuthService extends BaseService { await this.get('/oauth/logout'); } - static async login(request: LoginRequest): Promise { - return this.post('/user/login', request); + static async login(request: LoginRequest, headers?: Record): Promise { + return this.post('/user/login', request, headers ? { headers } as any : undefined); } static async register(request: RegisterRequest): Promise { diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index 4dc723c9..ebdbcc70 100644 --- a/frontend/lib/services/config/types.ts +++ b/frontend/lib/services/config/types.ts @@ -16,4 +16,8 @@ export interface PublicConfigResponse { oidc_login_enabled: boolean; /** 每个用户最大 API Key 数量 */ max_api_keys_per_user: number; + /** 是否启用 Cap 人机验证 */ + cap_login_enabled: boolean; + /** 是否自动解题 */ + cap_auto_solve: boolean; } diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index f23a24bb..be8cda7d 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -1,16 +1,16 @@ -import axios, { AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig } from 'axios'; -import { toast } from 'sonner'; -import { apiConfig } from './config'; +import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios'; +import {toast} from 'sonner'; +import {apiConfig} from './config'; import { ApiErrorBase, - NetworkError, - TimeoutError, ForbiddenError, + NetworkError, NotFoundError, ServerError, + TimeoutError, ValidationError, } from './errors'; -import { ApiError, ApiResponse } from './types'; +import {ApiError, ApiResponse} from './types'; /** * API 客户端实例 @@ -102,6 +102,11 @@ apiClient.interceptors.response.use( cancelTokens.delete(requestKey); pendingRequests.delete(requestKey); + const resData = response.data as any; + if (resData && resData.error_msg) { + return Promise.reject(new ApiErrorBase(resData.error_msg)); + } + return response; }, (error: AxiosError) => { diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 98b1af8e..691ec828 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -105,6 +105,7 @@ export type { ListUsersRequest, ListUsersResponse, UpdateUserStatusRequest, + SystemStatus, } from './admin'; // 用户服务 diff --git a/internal/apps/admin/status/routers.go b/internal/apps/admin/status/routers.go new file mode 100644 index 00000000..db31e635 --- /dev/null +++ b/internal/apps/admin/status/routers.go @@ -0,0 +1,189 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "fmt" + "math" + "net/http" + "runtime" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/util" +) + +// startTime 记录服务启动时间 +var startTime = time.Now() + +// SystemStatusResponse 系统状态响应结构体 +type SystemStatusResponse struct { + Uptime string `json:"uptime"` + NumGoroutine int `json:"num_goroutine"` + Alloc string `json:"alloc"` + TotalAlloc string `json:"total_alloc"` + Sys string `json:"sys"` + Lookups uint64 `json:"lookups"` + Mallocs uint64 `json:"mallocs"` + Frees uint64 `json:"frees"` + HeapAlloc string `json:"heap_alloc"` + HeapSys string `json:"heap_sys"` + HeapIdle string `json:"heap_idle"` + HeapInuse string `json:"heap_inuse"` + HeapReleased string `json:"heap_released"` + HeapObjects uint64 `json:"heap_objects"` + StackInuse string `json:"stack_inuse"` + StackSys string `json:"stack_sys"` + MSpanInuse string `json:"mspan_inuse"` + MSpanSys string `json:"mspan_sys"` + MCacheInuse string `json:"mcache_inuse"` + MCacheSys string `json:"mcache_sys"` + BuckHashSys string `json:"buck_hash_sys"` + GCSys string `json:"gc_sys"` + OtherSys string `json:"other_sys"` + NextGC string `json:"next_gc"` + LastGCTime string `json:"last_gc_time"` + PauseTotalNs string `json:"pause_total_ns"` + LastPause string `json:"last_pause"` + NumGC uint32 `json:"num_gc"` +} + +// formatBytes 格式化字节大小 +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(bytes) / float64(div) + var suffix string + switch exp { + case 0: + suffix = "KiB" + case 1: + suffix = "MiB" + case 2: + suffix = "GiB" + default: + suffix = "TiB" + } + + // 格式化规则: + // - 如果是整数(如 16, 73, 105, 986, 112): + // - 如果 >= 10,则格式化为 "%.0f" (e.g. "16 KiB") + // - 如果 < 10,则格式化为 "%.1f" (e.g. "9.0 KiB") + // - 如果不是整数(如 5.8, 9.1, 7.6, 4.8):格式化为 "%.1f" + if value == math.Trunc(value) { + if value >= 10 { + return fmt.Sprintf("%.0f %s", value, suffix) + } + return fmt.Sprintf("%.1f %s", value, suffix) + } + return fmt.Sprintf("%.1f %s", value, suffix) +} + +// formatDuration 格式化时间持续时间 +func formatDuration(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + var res string + if days > 0 { + res += fmt.Sprintf("%d天", days) + } + if hours > 0 { + res += fmt.Sprintf("%d小时", hours) + } + if minutes > 0 { + res += fmt.Sprintf("%d分钟", minutes) + } + if seconds > 0 || res == "" { + res += fmt.Sprintf("%d秒钟", seconds) + } + return res +} + +// GetSystemStatus 获取系统状态信息 +// @Summary 获取系统状态信息 +// @Description 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=status.SystemStatusResponse} "获取成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/status [get] +func GetSystemStatus(c *gin.Context) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + uptime := formatDuration(time.Since(startTime)) + numGoroutine := runtime.NumGoroutine() + + var lastGCTime string + if m.LastGC > 0 { + lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC)))) + } else { + lastGCTime = "无" + } + + var lastPause string + if m.NumGC > 0 { + lastPause = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC-1)%256])/1e9) + } else { + lastPause = "0.000s" + } + + res := SystemStatusResponse{ + Uptime: uptime, + NumGoroutine: numGoroutine, + Alloc: formatBytes(m.Alloc), + TotalAlloc: formatBytes(m.TotalAlloc), + Sys: formatBytes(m.Sys), + Lookups: m.Lookups, + Mallocs: m.Mallocs, + Frees: m.Frees, + HeapAlloc: formatBytes(m.HeapAlloc), + HeapSys: formatBytes(m.HeapSys), + HeapIdle: formatBytes(m.HeapIdle), + HeapInuse: formatBytes(m.HeapInuse), + HeapReleased: formatBytes(m.HeapReleased), + HeapObjects: m.HeapObjects, + StackInuse: formatBytes(m.StackInuse), + StackSys: formatBytes(m.StackSys), + MSpanInuse: formatBytes(m.MSpanInuse), + MSpanSys: formatBytes(m.MSpanSys), + MCacheInuse: formatBytes(m.MCacheInuse), + MCacheSys: formatBytes(m.MCacheSys), + BuckHashSys: formatBytes(m.BuckHashSys), + GCSys: formatBytes(m.GCSys), + OtherSys: formatBytes(m.OtherSys), + NextGC: formatBytes(m.NextGC), + LastGCTime: lastGCTime, + PauseTotalNs: fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1e9), + LastPause: lastPause, + NumGC: m.NumGC, + } + + c.JSON(http.StatusOK, util.OK(res)) +} diff --git a/internal/apps/cap/middleware.go b/internal/apps/cap/middleware.go new file mode 100644 index 00000000..d29c7918 --- /dev/null +++ b/internal/apps/cap/middleware.go @@ -0,0 +1,34 @@ +package cap + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/util" + caputil "github.com/linux-do/credit/internal/util/cap" +) + +// 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 *caputil.Manager, scope string, enabledFunc func() bool) gin.HandlerFunc { + return func(c *gin.Context) { + if enabledFunc != nil && !enabledFunc() { + c.Next() + return + } + + token := c.GetHeader("X-Cap-Token") + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码验证失败,缺少验证码凭证")) + return + } + + valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) + if err != nil || !valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败或已过期,请重试")) + return + } + + c.Next() + } +} diff --git a/internal/apps/cap/routers.go b/internal/apps/cap/routers.go new file mode 100644 index 00000000..d2f7299c --- /dev/null +++ b/internal/apps/cap/routers.go @@ -0,0 +1,92 @@ +package cap + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/util/cap" +) + +type challengeRequest struct { + Scope string `json:"scope" form:"scope"` +} + +type redeemRequest struct { + Token string `json:"token" binding:"required"` + Solutions []int `json:"solutions" binding:"required"` + Scope string `json:"scope" form:"scope"` +} + +// Challenge 生成 PoW 人机验证难题 +// @Summary 生成人机验证难题 +// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 +// @Tags cap +// @Accept json +// @Produce json +// @Param request body challengeRequest false "可选范围限制参数" +// @Success 200 {object} cap.ChallengeResponse "成功返回 PoW 难题" +// @Failure 500 {object} cap.RedeemResponse "内部服务错误" +// @Router /api/cap/challenge [post] +func Challenge(c *gin.Context) { + var req challengeRequest + _ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope + + if req.Scope == "" { + req.Scope = "login" + } + + mgr := cap.GetDefaultManager() + resp, err := mgr.Generate(c.Request.Context(), req.Scope) + if err != nil { + c.JSON(http.StatusInternalServerError, cap.RedeemResponse{ + Success: false, + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// Redeem 提交 PoW 解答并兑换一次性凭证 Token +// @Summary 校验人机验证解答 +// @Description 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 +// @Tags cap +// @Accept json +// @Produce json +// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组" +// @Success 200 {object} cap.RedeemResponse "核销成功,返回 X-Cap-Token" +// @Failure 400 {object} cap.RedeemResponse "参数错误或核销失败" +// @Failure 500 {object} cap.RedeemResponse "内部服务错误" +// @Router /api/cap/redeem [post] +func Redeem(c *gin.Context) { + var req redeemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, cap.RedeemResponse{ + Success: false, + Error: "无效的参数", + }) + return + } + + if req.Scope == "" { + req.Scope = "login" + } + + mgr := cap.GetDefaultManager() + resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope) + if err != nil { + c.JSON(http.StatusInternalServerError, cap.RedeemResponse{ + Success: false, + Error: err.Error(), + }) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, resp) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/apps/cap/routers_test.go b/internal/apps/cap/routers_test.go new file mode 100644 index 00000000..fde96c8b --- /dev/null +++ b/internal/apps/cap/routers_test.go @@ -0,0 +1,132 @@ +package cap + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" + capUtil "github.com/linux-do/credit/internal/util/cap" +) + +func TestCapEndpointsAndMiddleware(t *testing.T) { + sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + gin.SetMode(gin.TestMode) + r := gin.New() + + // Mount CAPTCHA API endpoints + capGroup := r.Group("/api/cap") + { + capGroup.POST("/challenge", Challenge) + capGroup.POST("/redeem", Redeem) + } + + // Login endpoint with CAPTCHA middleware + r.POST("/api/v1/user/login", VerifyMiddleware(capUtil.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, util.OK("login success")) + }) + + // 1. Test challenge generation + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/cap/challenge", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var challengeResp capUtil.ChallengeResponse + if err := json.Unmarshal(w.Body.Bytes(), &challengeResp); err != nil { + t.Fatalf("failed to unmarshal challenge response: %v", err) + } + + if challengeResp.Token == "" { + t.Fatalf("expected token in challenge response") + } + + // 2. Test login with CAPTCHA disabled (should pass) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String()) + } + + // 3. Enable CAPTCHA in DB + 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) + + // 4. Test login with CAPTCHA enabled but no header (should be blocked) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized, got %d. Body: %s", w.Code, w.Body.String()) + } + + // 5. Solve the challenge + solutions := capUtil.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D) + + // 6. Redeem solutions + redeemReqPayload := redeemRequest{ + Token: challengeResp.Token, + Solutions: solutions, + } + bodyBytes, _ := json.Marshal(redeemReqPayload) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/cap/redeem", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String()) + } + + var redeemResp capUtil.RedeemResponse + if err := json.Unmarshal(w.Body.Bytes(), &redeemResp); err != nil { + t.Fatalf("failed to unmarshal redeem response: %v", err) + } + + if !redeemResp.Success || redeemResp.Token == "" { + t.Fatalf("redeem failed or returned empty token: %+v", redeemResp) + } + + // 7. Login with valid redeem token (should pass) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) + req.Header.Set("X-Cap-Token", redeemResp.Token) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK with valid cap token, got %d. Body: %s", w.Code, w.Body.String()) + } + + // 8. Replay attack: Login with the same redeem token again (should be blocked as it is single-use) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) + req.Header.Set("X-Cap-Token", redeemResp.Token) + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized on replayed token, got %d. Body: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index 0295cd7f..0389e464 100644 --- a/internal/apps/config/routers.go +++ b/internal/apps/config/routers.go @@ -33,6 +33,8 @@ type PublicConfigResponse struct { PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册 OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录 MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量 + CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证 + CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算 } // GetPublicConfig 获取公共配置 @@ -83,6 +85,18 @@ func GetPublicConfig(c *gin.Context) { oidcLoginEnabled = val } + // 3.4 cap_login_enabled + var capLoginEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled); err == nil { + capLoginEnabled = val + } + + // 3.5 cap_auto_solve + capAutoSolve := true // 默认自动开始 + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapAutoSolve); err == nil { + capAutoSolve = val + } + // 4. max_api_keys_per_user var maxAPIKeys int if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil { @@ -97,6 +111,8 @@ func GetPublicConfig(c *gin.Context) { PasswordRegisterEnabled: passwordRegisterEnabled, OIDCLoginEnabled: oidcLoginEnabled, MaxAPIKeysPerUser: maxAPIKeys, + CapLoginEnabled: capLoginEnabled, + CapAutoSolve: capAutoSolve, } c.JSON(http.StatusOK, util.OK(response)) diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index a11573a0..38de74bf 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -52,6 +52,25 @@ func Migrate() { initDefaultAdmin() } +// ensureConfigKeyExists ensures a system config key exists in the database +func ensureConfigKeyExists(key, value, configType, description string) { + tx := db.DB(context.Background()) + var cfg model.SystemConfig + if err := tx.Where("key = ?", key).First(&cfg).Error; err != nil { + newConfig := model.SystemConfig{ + Key: key, + Value: value, + Type: configType, + Description: description, + } + if err := tx.Create(&newConfig).Error; err != nil { + log.Printf("[PostgreSQL] failed to create system config key %s: %v\n", key, err) + } else { + log.Printf("[PostgreSQL] initialized system config key %s\n", key) + } + } +} + // initSystemConfigs 初始化系统配置数据 func initSystemConfigs() { tx := db.DB(context.Background()) @@ -63,10 +82,59 @@ func initSystemConfigs() { } if count > 0 { + ensureConfigKeyExists(model.ConfigKeyCapLoginEnabled, "false", "system", "是否启用登录人机验证(true/false)") + ensureConfigKeyExists(model.ConfigKeyCapAutoSolve, "true", "system", "打开页面后是否自动开始计算,关闭则需用户手动点击触发") + ensureConfigKeyExists(model.ConfigKeyCapChallengeCount, "1", "system", "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5") + ensureConfigKeyExists(model.ConfigKeyCapChallengeSize, "32", "system", "人机验证盐值长度") + ensureConfigKeyExists(model.ConfigKeyCapChallengeDifficulty, "4", "system", "人机验证 PoW 难度(目标前缀长度)") + ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)") + ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)") return } defaultConfigs := []model.SystemConfig{ + { + Key: model.ConfigKeyCapLoginEnabled, + Value: "false", + Type: "system", + Description: "是否启用登录人机验证(true/false)", + }, + { + Key: model.ConfigKeyCapAutoSolve, + Value: "true", + Type: "system", + Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发", + }, + { + Key: model.ConfigKeyCapChallengeCount, + Value: "1", + Type: "system", + Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", + }, + { + Key: model.ConfigKeyCapChallengeSize, + Value: "32", + Type: "system", + Description: "人机验证盐值长度", + }, + { + Key: model.ConfigKeyCapChallengeDifficulty, + Value: "4", + Type: "system", + Description: "人机验证 PoW 难度(目标前缀长度)", + }, + { + Key: model.ConfigKeyCapChallengeTTL, + Value: "600", + Type: "system", + Description: "人机验证难题有效时间(秒)", + }, + { + Key: model.ConfigKeyCapTokenTTL, + Value: "1200", + Type: "system", + Description: "人机验证兑换凭证有效时间(秒)", + }, { Key: model.ConfigKeyUploadAllowedExtensions, Value: "jpg,png,webp", diff --git a/internal/db/postgres.go b/internal/db/postgres.go index 4bef57f9..c14c838a 100644 --- a/internal/db/postgres.go +++ b/internal/db/postgres.go @@ -173,6 +173,9 @@ func buildDSN(host string, port int, username, password string) string { } func DB(ctx context.Context) *gorm.DB { + if db == nil { + return nil + } return db.WithContext(ctx) } diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 9782e557..40803e21 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -38,6 +38,13 @@ const ( ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量 + ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 + ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) + ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 + ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) + ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) + ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒) ) const ( @@ -56,20 +63,29 @@ type SystemConfig struct { // GetByKey 通过 key 查询配置(带 Redis 缓存) func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { - if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, sc); err == nil { - return nil - } else if !errors.Is(err, redis.Nil) { - // Redis 服务错误,返回错误 - return err + if db.Redis != nil { + if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, sc); err == nil { + return nil + } else if !errors.Is(err, redis.Nil) { + // Redis 服务错误,返回错误 + return err + } } // 查数据库 - if err := db.DB(ctx).Where("key = ?", key).First(sc).Error; err != nil { + database := db.DB(ctx) + if database == nil { + return errors.New("database not initialized") + } + + if err := database.Where("key = ?", key).First(sc).Error; err != nil { return err } // 更新 Redis Hash 缓存 - _ = db.HSetJSON(ctx, SystemConfigRedisHashKey, key, sc) + if db.Redis != nil { + _ = db.HSetJSON(ctx, SystemConfigRedisHashKey, key, sc) + } return nil } diff --git a/internal/router/router.go b/internal/router/router.go index 17f09b3d..8ceb8876 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -29,13 +29,17 @@ import ( "github.com/linux-do/credit/internal/apps/admin" admin_auth_source "github.com/linux-do/credit/internal/apps/admin/auth_source" + admin_status "github.com/linux-do/credit/internal/apps/admin/status" admin_task "github.com/linux-do/credit/internal/apps/admin/task" admin_user "github.com/linux-do/credit/internal/apps/admin/user" + capApp "github.com/linux-do/credit/internal/apps/cap" publicconfig "github.com/linux-do/credit/internal/apps/config" "github.com/linux-do/credit/internal/apps/health" "github.com/linux-do/credit/internal/apps/upload" "github.com/linux-do/credit/internal/apps/user" + "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/util" + capUtil "github.com/linux-do/credit/internal/util/cap" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/redis" @@ -104,6 +108,13 @@ func Serve() { apiGroup.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } + // CAPTCHA + capGroup := apiGroup.Group("/cap") + { + capGroup.POST("/challenge", capApp.Challenge) + capGroup.POST("/redeem", capApp.Redeem) + } + // API V1 apiV1Router := apiGroup.Group("/v1") { @@ -124,7 +135,13 @@ func Serve() { // User userRouter := apiV1Router.Group("/user") { - userRouter.POST("/login", user.Login) + userRouter.POST("/login", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "login", func() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled) + if err != nil { + return false + } + return enabled + }), user.Login) userRouter.POST("/register", user.Register) userRouter.GET("/logout", user.Logout) userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) @@ -162,6 +179,9 @@ func Serve() { adminRouter := apiV1Router.Group("/admin") adminRouter.Use(oauth.LoginRequired(), admin.LoginAdminRequired()) { + // System status + adminRouter.GET("/status", admin_status.GetSystemStatus) + // Task dispatch adminRouter.GET("/tasks/types", admin_task.ListTaskTypes) adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask) diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 1d5c268d..2ba19026 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -134,6 +134,48 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { Type: "business", Description: "限制每个普通用户可以创建的 API Key 最大数量", }, + { + Key: model.ConfigKeyCapLoginEnabled, + Value: "false", + Type: "system", + Description: "是否启用登录人机验证(true/false)", + }, + { + Key: model.ConfigKeyCapAutoSolve, + Value: "true", + Type: "system", + Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发", + }, + { + Key: model.ConfigKeyCapChallengeCount, + Value: "1", + Type: "system", + Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", + }, + { + Key: model.ConfigKeyCapChallengeSize, + Value: "32", + Type: "system", + Description: "人机验证盐值长度", + }, + { + Key: model.ConfigKeyCapChallengeDifficulty, + Value: "4", + Type: "system", + Description: "人机验证 PoW 难度(目标前缀长度)", + }, + { + Key: model.ConfigKeyCapChallengeTTL, + Value: "600", + Type: "system", + Description: "人机验证难题有效时间(秒)", + }, + { + Key: model.ConfigKeyCapTokenTTL, + Value: "1200", + Type: "system", + Description: "人机验证兑换凭证有效时间(秒)", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/util/cap/cap.go b/internal/util/cap/cap.go new file mode 100644 index 00000000..f173f9b7 --- /dev/null +++ b/internal/util/cap/cap.go @@ -0,0 +1,245 @@ +package cap + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "strings" + "time" +) + +const jwtHeaderB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + +// ChallengeConfig holds parameters for the PoW challenge +type ChallengeConfig struct { + Count int // Number of puzzles (c) + Size int // Salt length (s) + Difficulty int // Difficulty prefix length (d) + ExpiresMs time.Duration // Challenge TTL +} + +// ChallengeResponse is returned to the client +type ChallengeResponse struct { + Challenge struct { + C int `json:"c"` + S int `json:"s"` + D int `json:"d"` + } `json:"challenge"` + Token string `json:"token"` + Expires int64 `json:"expires"` // ms timestamp +} + +// ChallengePayload represents the signed JWT payload +type ChallengePayload struct { + Nonce string `json:"n"` + Count int `json:"c"` + Size int `json:"s"` + Difficulty int `json:"d"` + Expires int64 `json:"exp"` // ms timestamp + IssuedAt int64 `json:"iat"` // ms timestamp + Scope string `json:"sk,omitempty"` +} + +// RedeemRequest payload sent by client +type RedeemRequest struct { + Token string `json:"token"` + Solutions []int `json:"solutions"` +} + +// RedeemResponse returned to client after verification +type RedeemResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Expires int64 `json:"expires,omitempty"` + Error string `json:"error,omitempty"` +} + +func b64urlEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +func b64urlDecode(str string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(str) +} + +func randomHex(byteLen int) string { + bytes := make([]byte, byteLen) + if _, err := rand.Read(bytes); err != nil { + panic(err) + } + return hex.EncodeToString(bytes) +} + +func jwtSign(payload []byte, secret []byte) string { + body := b64urlEncode(payload) + sigInput := jwtHeaderB64 + "." + body + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(sigInput)) + sig := mac.Sum(nil) + + return sigInput + "." + b64urlEncode(sig) +} + +func jwtVerify(token string, secret []byte) ([]byte, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("invalid token format") + } + if parts[0] != jwtHeaderB64 { + return nil, errors.New("invalid header") + } + + sigInput := parts[0] + "." + parts[1] + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(sigInput)) + expectedSig := mac.Sum(nil) + + actualSig, err := b64urlDecode(parts[2]) + if err != nil { + return nil, err + } + + if !hmac.Equal(expectedSig, actualSig) { + return nil, errors.New("signature mismatch") + } + + payload, err := b64urlDecode(parts[1]) + if err != nil { + return nil, err + } + + return payload, nil +} + +func jwtSigHex(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + sigBytes, err := b64urlDecode(parts[2]) + if err != nil { + return "" + } + return hex.EncodeToString(sigBytes) +} + +// GenerateChallenge produces a new challenge and signed token +func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*ChallengeResponse, error) { + if conf.Count <= 0 { + conf.Count = 50 + } + if conf.Size <= 0 { + conf.Size = 32 + } + if conf.Difficulty <= 0 { + conf.Difficulty = 4 + } + if conf.ExpiresMs <= 0 { + conf.ExpiresMs = 10 * time.Minute + } + + now := time.Now().UnixNano() / int64(time.Millisecond) + expires := now + int64(conf.ExpiresMs/time.Millisecond) + + payload := ChallengePayload{ + Nonce: randomHex(25), + Count: conf.Count, + Size: conf.Size, + Difficulty: conf.Difficulty, + Expires: expires, + IssuedAt: now, + Scope: scope, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + token := jwtSign(payloadBytes, secret) + + resp := &ChallengeResponse{ + Token: token, + Expires: expires, + } + resp.Challenge.C = conf.Count + resp.Challenge.S = conf.Size + resp.Challenge.D = conf.Difficulty + + return resp, nil +} + +// VerifyChallengeSolutions verifies client submitted solutions +func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) { + payloadBytes, err := jwtVerify(token, secret) + if err != nil { + return nil, errors.New("invalid_token") + } + + var payload ChallengePayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return nil, errors.New("invalid_token") + } + + if expectedScope != "" && payload.Scope != expectedScope { + return nil, errors.New("scope_mismatch") + } + + now := time.Now().UnixNano() / int64(time.Millisecond) + if payload.Expires < now { + return nil, errors.New("expired") + } + + if len(solutions) != payload.Count { + return nil, errors.New("invalid_solutions") + } + + tokenFnv := fnv1a(token) + for i := 0; i < payload.Count; i++ { + idxStr := strconv.Itoa(i + 1) + saltSeed := fnv1aResume(tokenFnv, idxStr) + targetSeed := fnv1aResume(saltSeed, "d") + salt := prngFromHash(saltSeed, payload.Size) + target := prngFromHash(targetSeed, payload.Difficulty) + + hashInput := salt + strconv.Itoa(solutions[i]) + hashBytes := sha256.Sum256([]byte(hashInput)) + hashHex := hex.EncodeToString(hashBytes[:]) + + if !strings.HasPrefix(hashHex, target) { + return nil, errors.New("invalid_solution") + } + } + + return &payload, nil +} + +// Solve is a utility function to solve a challenge (mainly used for tests and reference implementation) +func Solve(token string, count, size, difficulty int) []int { + solutions := make([]int, count) + tokenFnv := fnv1a(token) + for i := 0; i < count; i++ { + idxStr := strconv.Itoa(i + 1) + saltSeed := fnv1aResume(tokenFnv, idxStr) + targetSeed := fnv1aResume(saltSeed, "d") + salt := prngFromHash(saltSeed, size) + target := prngFromHash(targetSeed, difficulty) + + for nonce := 0; nonce < 1000000; nonce++ { + hashInput := salt + strconv.Itoa(nonce) + hashBytes := sha256.Sum256([]byte(hashInput)) + hashHex := hex.EncodeToString(hashBytes[:]) + if strings.HasPrefix(hashHex, target) { + solutions[i] = nonce + break + } + } + } + return solutions +} diff --git a/internal/util/cap/cap_test.go b/internal/util/cap/cap_test.go new file mode 100644 index 00000000..afb062d9 --- /dev/null +++ b/internal/util/cap/cap_test.go @@ -0,0 +1,167 @@ +package cap + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestCapFullFlow(t *testing.T) { + secret := []byte("a-very-long-secret-key-at-least-16-bytes") + store := NewMemoryStore(1 * time.Minute) + + manager := NewManager(Config{ + Secret: secret, + ChallengeCount: 3, // small count for fast test + ChallengeSize: 32, + ChallengeDifficulty: 3, // small difficulty for fast test + ChallengeTTL: 5 * time.Second, + TokenTTL: 10 * time.Second, + }, store) + + scope := "test-scope" + ctx := context.Background() + resp, err := manager.Generate(ctx, scope) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + if resp.Challenge.C != 3 { + t.Errorf("Expected count 3, got %d", resp.Challenge.C) + } + + // Solve the challenge (acting as client) + solutions := 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) + } + if !redeemResp.Success { + t.Fatalf("Redeem returned success=false: %s", redeemResp.Error) + } + if redeemResp.Token == "" { + t.Fatalf("Expected token, got empty") + } + + // Verify the token + valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope) + if err != nil { + t.Fatalf("VerifyToken failed: %v", err) + } + if !valid { + t.Fatalf("Expected redeem token to be valid") + } + + // 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) + } + if validAgain { + t.Fatalf("Expected redeem token to be single-use (invalidated after verification)") + } +} + +// 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 + + secret := []byte("race-test-secret-key-at-least-16-bytes") + store := 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, err := manager.Generate(ctx, "login") + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + solutions := 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 := 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 := 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++ { + wg.Add(1) + go func() { + defer wg.Done() + <-barrier + ok, _ := manager.VerifyToken(ctx, capToken, "login") + if ok { + success.Add(1) + } + }() + } + close(barrier) + wg.Wait() + + if n := success.Load(); n != 1 { + t.Fatalf("Expected exactly 1 successful VerifyToken, got %d", n) + } +} diff --git a/internal/util/cap/manager.go b/internal/util/cap/manager.go new file mode 100644 index 00000000..988e367d --- /dev/null +++ b/internal/util/cap/manager.go @@ -0,0 +1,271 @@ +package cap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "sync" + "time" + + "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" +) + +// 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 +type Manager struct { + conf Config + store Store +} + +// NewManager creates a new CAPTCHA Manager +func NewManager(conf Config, store Store) *Manager { + if conf.ChallengeCount <= 0 { + conf.ChallengeCount = 1 + } + if conf.ChallengeSize <= 0 { + conf.ChallengeSize = 32 + } + if conf.ChallengeDifficulty <= 0 { + conf.ChallengeDifficulty = 4 + } + if conf.ChallengeTTL <= 0 { + conf.ChallengeTTL = 10 * time.Minute + } + if conf.TokenTTL <= 0 { + conf.TokenTTL = 20 * time.Minute + } + return &Manager{ + conf: conf, + store: store, + } +} + +// Generate creates a challenge response +func (m *Manager) Generate(ctx context.Context, scope string) (*ChallengeResponse, error) { + c := ChallengeConfig{ + Count: m.getChallengeCount(ctx), + Size: m.getChallengeSize(ctx), + Difficulty: m.getChallengeDifficulty(ctx), + ExpiresMs: m.getChallengeTTL(ctx), + } + return GenerateChallenge(m.conf.Secret, c, scope) +} + +// 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 := jwtSigHex(token) + if sigHex == "" { + return &RedeemResponse{Success: false, Error: "invalid_token"}, nil + } + + nonceKey := "cap:nonce:" + sigHex + + // Atomically claim the nonce slot BEFORE verifying solutions. + // SetNX returns true only when the key did not previously exist, so two + // concurrent requests carrying the same JWT can never both succeed here. + // TTL is set to the challenge's remaining lifetime so the slot auto-expires. + payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) + if err != nil { + return &RedeemResponse{Success: false, Error: err.Error()}, nil + } + + // 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 + } + if !set { + return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil + } + + // Generate a redeem token formatted as "id:verToken" + id := randomHex(8) + verToken := randomHex(15) + 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" + storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope + + if err := m.store.Set(ctx, tokenKey, storeVal, tokenTTL); err != nil { + return &RedeemResponse{Success: false, Error: "token_store_error"}, err + } + + return &RedeemResponse{ + Success: true, + Token: id + ":" + verToken, + Expires: tokenExpires.UnixNano() / int64(time.Millisecond), + }, nil +} + +// VerifyToken validates and consumes the redeem token (single-use). +// GetAndDelete is used so that retrieval and removal happen atomically: +// two concurrent requests carrying the same token can never both see a value. +func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) { + if token == "" { + return false, nil + } + parts := strings.Split(token, ":") + if len(parts) != 2 { + return false, nil + } + id := parts[0] + verToken := parts[1] + + verHashBytes := sha256.Sum256([]byte(verToken)) + verHashHex := hex.EncodeToString(verHashBytes[:]) + + tokenKey := "cap:token:" + id + ":" + verHashHex + + // Atomically retrieve-and-delete: the first caller gets the value, any + // subsequent caller (even concurrent) receives (false, nil) immediately. + val, exists, err := sGetAndDelete(ctx, m.store, tokenKey) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + valParts := strings.Split(val, "|") + if len(valParts) != 2 { + return false, nil + } + + expNano, err := strconv.ParseInt(valParts[0], 10, 64) + if err != nil { + return false, nil + } + tokenScope := valParts[1] + + if expectedScope != "" && tokenScope != expectedScope { + return false, nil + } + + if time.Now().UnixNano() > expNano { + return false, nil // Expired + } + + return true, nil +} + +// sGet safely calls store.Get, treating a nil store as a miss. +func sGet(ctx context.Context, store Store, key string) (string, bool, error) { + if store == nil { + return "", false, nil + } + return store.Get(ctx, key) +} + +// sGetAndDelete safely calls store.GetAndDelete, treating a nil store as a miss. +func sGetAndDelete(ctx context.Context, store Store, key string) (string, bool, error) { + if store == nil { + return "", false, nil + } + 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 +func GetDefaultManager() *Manager { + once.Do(func() { + var secret []byte + 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 := 1 + challengeSize := 32 + challengeDifficulty := 4 + challengeTTL := 10 * time.Minute + tokenTTL := 20 * time.Minute + + var store Store + if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil { + store = NewRedisStore(db.Redis) + } else { + store = NewMemoryStore(1 * time.Minute) + } + + defaultManager = NewManager(Config{ + Secret: secret, + ChallengeCount: challengeCount, + ChallengeSize: challengeSize, + ChallengeDifficulty: challengeDifficulty, + ChallengeTTL: challengeTTL, + TokenTTL: tokenTTL, + }, store) + }) + return defaultManager +} diff --git a/internal/util/cap/prng.go b/internal/util/cap/prng.go new file mode 100644 index 00000000..f85bbd4e --- /dev/null +++ b/internal/util/cap/prng.go @@ -0,0 +1,45 @@ +package cap + +import ( + "fmt" + "strings" +) + +// fnv1a returns the 32-bit FNV-1a hash of a string +func fnv1a(str string) uint32 { + var hash uint32 = 2166136261 + for i := 0; i < len(str); i++ { + hash ^= uint32(str[i]) + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24) + } + return hash +} + +// fnv1aResume resumes FNV-1a hashing from a given state +func fnv1aResume(state uint32, str string) uint32 { + h := state + for i := 0; i < len(str); i++ { + h ^= uint32(str[i]) + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24) + } + return h +} + +// prng generates a hex string of specified length using a seed +func prng(seed string, length int) string { + return prngFromHash(fnv1a(seed), length) +} + +// prngFromHash generates a hex string of specified length using an initial hash state +func prngFromHash(initialHash uint32, length int) string { + state := initialHash + var result strings.Builder + for result.Len() < length { + state ^= state << 13 + state ^= state >> 17 + state ^= state << 5 + hexStr := fmt.Sprintf("%08x", state) + result.WriteString(hexStr) + } + return result.String()[:length] +} diff --git a/internal/util/cap/store.go b/internal/util/cap/store.go new file mode 100644 index 00000000..84b4b2fa --- /dev/null +++ b/internal/util/cap/store.go @@ -0,0 +1,177 @@ +package cap + +import ( + "context" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// Store defines the storage interface for challenge nonces and verification tokens +type Store interface { + Get(ctx context.Context, key string) (string, bool, error) + Set(ctx context.Context, key string, val string, ttl time.Duration) error + Delete(ctx context.Context, key string) error + // SetNX atomically sets key=val with the given TTL only when the key does not + // exist yet. It returns true when the key was actually written (i.e. this + // caller "won" the race), and false when the key already existed. + SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) + // GetAndDelete atomically retrieves the value of key and removes it in a + // single operation. Returns ("", false, nil) when the key does not exist. + GetAndDelete(ctx context.Context, key string) (string, bool, error) +} + +type memoryItem struct { + value string + expiresAt time.Time +} + +// MemoryStore is a thread-safe in-memory implementation of Store +type MemoryStore struct { + items map[string]memoryItem + mu sync.Mutex // unified write-lock; promotes to exclusive for all ops +} + +// NewMemoryStore creates and initializes a new MemoryStore +func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore { + store := &MemoryStore{ + items: make(map[string]memoryItem), + } + if cleanupInterval > 0 { + go store.startCleanupLoop(cleanupInterval) + } + return store +} + +func (s *MemoryStore) Get(ctx context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.getLocked(key) +} + +// getLocked is the internal helper – caller must hold s.mu. +func (s *MemoryStore) getLocked(key string) (string, bool, error) { + item, found := s.items[key] + if !found { + return "", false, nil + } + if time.Now().After(item.expiresAt) { + delete(s.items, key) + return "", false, nil + } + return item.value, true, nil +} + +func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.items[key] = memoryItem{ + value: val, + expiresAt: time.Now().Add(ttl), + } + return nil +} + +func (s *MemoryStore) Delete(ctx context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, key) + return nil +} + +// SetNX atomically sets key only when it is absent (or expired). +// Returns true if the key was written by this call. +func (s *MemoryStore) SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + _, exists, _ := s.getLocked(key) + if exists { + return false, nil + } + s.items[key] = memoryItem{ + value: val, + expiresAt: time.Now().Add(ttl), + } + return true, nil +} + +// GetAndDelete atomically retrieves and removes key in one critical section. +func (s *MemoryStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + val, exists, err := s.getLocked(key) + if err != nil || !exists { + return "", false, err + } + delete(s.items, key) + return val, true, nil +} + +func (s *MemoryStore) startCleanupLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + for range ticker.C { + s.cleanupExpired() + } +} + +func (s *MemoryStore) cleanupExpired() { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + for k, v := range s.items { + if now.After(v.expiresAt) { + delete(s.items, k) + } + } +} + +// RedisStore is a GORM-compatible/standalone Redis-backed implementation of Store +type RedisStore struct { + client redis.UniversalClient +} + +// NewRedisStore creates a new RedisStore wrapping a redis.UniversalClient +func NewRedisStore(client redis.UniversalClient) *RedisStore { + return &RedisStore{ + client: client, + } +} + +func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) { + val, err := s.client.Get(ctx, key).Result() + if err == redis.Nil { + return "", false, nil + } + if err != nil { + return "", false, err + } + return val, true, nil +} + +func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error { + return s.client.Set(ctx, key, val, ttl).Err() +} + +func (s *RedisStore) Delete(ctx context.Context, key string) error { + return s.client.Del(ctx, key).Err() +} + +// SetNX wraps Redis SET NX – returns true only when the key was newly created. +func (s *RedisStore) SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) { + return s.client.SetNX(ctx, key, val, ttl).Result() +} + +// GetAndDelete wraps Redis GETDEL (available since Redis 6.2). +func (s *RedisStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) { + val, err := s.client.GetDel(ctx, key).Result() + if err == redis.Nil { + return "", false, nil + } + if err != nil { + return "", false, err + } + return val, true, nil +}