Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
package middleware
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/database"
"github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/models/vo"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/utils"
"github.com/gin-gonic/gin"
)
// AuthRequired 认证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 基础的 CSRF 防护:校验 Origin/Referer (针对非 GET 请求)
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodOptions && c.Request.Method != http.MethodHead {
origin := c.GetHeader("Origin")
if origin == "" {
origin = c.GetHeader("Referer")
}
// 如果有 Origin 且不匹配则拒绝(实际部署时应配置允许的 Origin)
if origin != "" && !utils.CheckWSOrigin(c.Request) {
utils.Forbidden(c, "CSRF 校验失败: 非法的请求来源")
c.Abort()
return
}
}
// 检查是否携带互联 Token(支持跨面板远程全接口调用)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if tokenStr != "" {
settingsSvc := services.NewSettingsService()
interconnectToken := settingsSvc.Get(constant.SectionSite, constant.KeyInterconnectToken)
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
isMatched := false
h1 := sha256.Sum256([]byte(tokenStr))
if interconnectToken != "" {
h2 := sha256.Sum256([]byte(interconnectToken))
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
isMatched = true
}
}
if !isMatched && parentToken != "" {
h2 := sha256.Sum256([]byte(parentToken))
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
isMatched = true
}
}
if isMatched {
// 模拟 Admin 角色
var adminUser models.User
res := database.DB.Where("role = ?", constant.AdminRole).Limit(1).Find(&adminUser)
if res.Error == nil && res.RowsAffected > 0 {
c.Set("userID", adminUser.ID)
c.Set("username", adminUser.Username)
c.Set("role", adminUser.Role)
c.Next()
return
}
}
}
}
token, err := c.Cookie(constant.CookieName)
if err != nil || token == "" {
utils.Unauthorized(c, "请先登录")
c.Abort()
return
}
// 验证 token
userID, username, tokenVersion, err := utils.ParseToken(token, constant.Secret)
if err != nil {
utils.Unauthorized(c, "登录已过期,请重新登录")
c.Abort()
return
}
// 安全增强:校验数据库中该用户的 ID 是否与 Token 一致,并验证 TokenVersion
var user models.User
res := database.DB.Where("username = ?", username).Limit(1).Find(&user)
if res.Error != nil || res.RowsAffected == 0 || user.ID != userID || user.TokenVersion != tokenVersion {
utils.Unauthorized(c, "会话失效,请重新登录")
ClearAuthCookie(c)
c.Abort()
return
}
// 将用户信息存入上下文 (必须使用数据库中的最新 ID)
c.Set("userID", user.ID)
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Next()
}
}
// AdminRequired 管理员权限认证中间件
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || role != constant.AdminRole {
utils.Forbidden(c, "需要管理员权限")
c.Abort()
return
}
c.Next()
}
}
// OpenapiRequired OpenAPI 认证中间件
func OpenapiRequired() gin.HandlerFunc {
settingsSvc := services.NewSettingsService()
return func(c *gin.Context) {
if checkOpenapiToken(c, settingsSvc) {
return
}
utils.Unauthorized(c, "无效的 OpenAPI 令牌")
c.Abort()
}
}
// checkOpenapiToken 校验 OpenAPI Token
// 返回 true 表示校验通过并已放行请求
func checkOpenapiToken(c *gin.Context, settingsSvc *services.SettingsService) bool {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return false
}
// 提取 token:支持 "Bearer <token>" 和直接 "<token>" 两种格式
var openapiToken string
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
// 标准格式:Bearer <token>
openapiToken = authHeader[7:]
} else {
// 直接使用 token
openapiToken = authHeader
}
// Token 不能为空
if openapiToken == "" {
return false
}
siteConfig := settingsSvc.GetSection(constant.SectionSite)
tokenJson, ok := siteConfig[constant.KeyOpenapiToken]
if !ok || tokenJson == "" {
return false
}
var tokenConfig vo.TokenConfig
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
return false
}
// 校验开启状态
if !tokenConfig.Enabled {
return false
}
if tokenConfig.Token == "" {
return false
}
// 使用恒定时间比较防止时序攻击
h1 := sha256.Sum256([]byte(openapiToken))
h2 := sha256.Sum256([]byte(tokenConfig.Token))
if subtle.ConstantTimeCompare(h1[:], h2[:]) != 1 {
return false
}
// 检查过期时间
if tokenConfig.ExpireAt != "" {
expireDate, err := time.Parse("2006-01-02", tokenConfig.ExpireAt)
if err == nil {
expireDate = expireDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
if time.Now().After(expireDate) {
return false
}
}
}
// 模拟 Admin 角色
var adminUser models.User
res := database.DB.Where("role = ?", "admin").Limit(1).Find(&adminUser)
if res.Error != nil || res.RowsAffected == 0 {
utils.Unauthorized(c, "未找到管理员账户,OpenAPI Token 校验失败")
c.Abort()
return true
}
c.Set("userID", adminUser.ID)
c.Set("username", adminUser.Username)
c.Set("role", adminUser.Role)
c.Next()
return true
}
// SetAuthCookie 设置认证 CookieexpireDays 为过期天数
func SetAuthCookie(c *gin.Context, token string, expireDays int) {
maxAge := 86400 * expireDays
// 增加 SameSite=Lax 和 Secure 属性(如果环境支持,这里暂时设为 false,但生产建议 true)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(constant.CookieName, token, maxAge, "/", "", false, true)
}
// ClearAuthCookie 清除认证 Cookie
func ClearAuthCookie(c *gin.Context) {
c.SetCookie(constant.CookieName, "", -1, "/", "", false, true)
}
// SwaggerAuth Swagger 认证中间件 (Basic Auth)
func SwaggerAuth() gin.HandlerFunc {
return func(c *gin.Context) {
settingsSvc := services.NewSettingsService()
siteConfig := settingsSvc.GetSection(constant.SectionSite)
tokenJson := siteConfig[constant.KeyOpenapiToken]
if tokenJson == "" {
c.Status(http.StatusNotFound)
c.Abort()
return
}
var tokenConfig vo.TokenConfig
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
c.Status(http.StatusNotFound)
c.Abort()
return
}
// 必须开启鉴权开关
if !tokenConfig.Enabled {
c.Status(http.StatusNotFound)
c.Abort()
return
}
// 检查过期时间
if tokenConfig.ExpireAt != "" {
expire, err := time.ParseInLocation("2006/01/02", tokenConfig.ExpireAt, time.Local)
if err == nil {
// 包含当天,所以设置到当天 23:59:59
expire = expire.Add(24*time.Hour - time.Second)
if time.Now().After(expire) {
c.Status(http.StatusNotFound)
c.Abort()
return
}
}
}
// 获取请求中携带的凭证
// 1. URL 参数 token
// 2. Cookie 中的 openapi_token
// 3. HTTP Basic Auth
tokenQuery := c.Query("token")
tokenCookie, _ := c.Cookie("openapi_token")
_, password, hasAuth := c.Request.BasicAuth()
var providedToken string
if tokenQuery != "" {
providedToken = tokenQuery
} else if tokenCookie != "" {
providedToken = tokenCookie
} else if hasAuth {
providedToken = password
}
// 检查提供的 token 是否匹配
if providedToken != "" {
h1 := sha256.Sum256([]byte(providedToken))
h2 := sha256.Sum256([]byte(tokenConfig.Token))
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
// 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json)
if tokenQuery != "" {
c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false)
}
c.Next()
return
}
}
// 验证失败,不再返回 WWW-Authenticate 头触发浏览器反人类原生弹窗
// 我们返回 401 的 JSON 或纯文本结构,以便由调用方自行接管鉴权逻辑
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "OpenAPI 访问未授权或 Token 错误",
})
c.Abort()
}
}
// LocalhostOnly 仅允许本地回环地址访问,并进行简单的内部凭证校验
func LocalhostOnly() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if ip != "127.0.0.1" && ip != "::1" {
utils.BadRequest(c, "仅允许本地访问")
c.Abort()
return
}
// 简单的内部通信认证
token := c.GetHeader("X-Internal-Token")
if token == "" || token != constant.Secret {
utils.Unauthorized(c, "无效的内部调用凭证")
c.Abort()
return
}
c.Next()
}
}
+63
View File
@@ -0,0 +1,63 @@
package middleware
import (
"fmt"
"time"
"github.com/engigu/taskpool/internal/logger"
"github.com/gin-gonic/gin"
)
// GinLogger 返回使用 logrus 的 Gin 日志中间件
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
method := c.Request.Method
if query != "" {
path = path + "?" + query
}
// 格式化延迟时间
var latencyStr string
if latency < time.Millisecond {
latencyStr = fmt.Sprintf("%dµs", latency.Microseconds())
} else if latency < time.Second {
latencyStr = fmt.Sprintf("%dms", latency.Milliseconds())
} else {
latencyStr = fmt.Sprintf("%.2fs", latency.Seconds())
}
msg := fmt.Sprintf("[HTTP] %d %s %s %s [%s]", status, method, path, latencyStr, clientIP)
if status >= 500 {
logger.Error(msg)
} else if status >= 400 {
logger.Warn(msg)
} else {
logger.Info(msg)
}
}
}
// GinRecovery 返回使用 logrus 的 Gin 恢复中间件
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger.Errorf("[HTTP] Panic: %v | %s", err, c.Request.URL.Path)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
+40
View File
@@ -0,0 +1,40 @@
package middleware
import (
"strings"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/utils"
"github.com/gin-gonic/gin"
)
// NotifyTokenAuth 通知 Token 认证中间件
func NotifyTokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("notify-token")
if token == "" {
utils.Unauthorized(c, "缺少通知 Token")
c.Abort()
return
}
// 从 settings 表读取配置的通知 Token
settingsService := services.NewSettingsService()
savedToken := settingsService.Get(constant.SectionNotify, constant.KeyNotifyToken)
if savedToken == "" {
utils.Unauthorized(c, "通知 Token 未配置")
c.Abort()
return
}
if !strings.EqualFold(token, savedToken) {
utils.Unauthorized(c, "通知 Token 无效")
c.Abort()
return
}
c.Next()
}
}
+133
View File
@@ -0,0 +1,133 @@
package middleware
import (
"context"
"io"
"net/http"
"strings"
"time"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/database"
"github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/tunnel"
"github.com/gin-gonic/gin"
)
var travelProxyClient = &http.Client{
Timeout: 10 * time.Second,
}
func TravelProxyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 检查是否存在 active_interconnect_node_id Cookie
nodeID, err := c.Cookie(constant.CookieActiveInterconnectNodeID)
if err != nil || nodeID == "" {
c.Next()
return
}
// 2. 白名单放行:反向隧道建立连接端点必须直达本机,不能二次代理
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/v1/interconnect/tunnel") {
c.Next()
return
}
// 3. 查询数据库中节点信息
var node models.InterconnectNode
if err := database.DB.Where("id = ?", nodeID).First(&node).Error; err != nil {
// 节点不存在,说明 Cookie 无效,清除并放行
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Next()
return
}
// 4. 准备代理的路径(若主节点配置了 URLPrefix,需要剥离)
cfg := services.GetConfig()
urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/")
targetPath := path
if urlPrefix != "" {
targetPath = strings.TrimPrefix(targetPath, urlPrefix)
}
if !strings.HasPrefix(targetPath, "/") {
targetPath = "/" + targetPath
}
// 5. 执行代理转发
if strings.HasPrefix(node.URL, "tunnel://") {
// 走 WebSocket 逆向 Yamux 隧道
err := tunnel.ProxyHTTP(nodeID, c, targetPath)
if err != nil {
// 如果是网页 HTML 导航请求,提供友好降级返回主节点
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
c.Abort()
return
}
c.JSON(502, gin.H{"code": 502, "msg": "与子节点逆向隧道通信异常: " + err.Error()})
c.Abort()
return
}
c.Abort()
return
}
// 走普通 HTTP 直连代理
targetURL := strings.TrimRight(node.URL, "/") + targetPath
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
if err != nil {
c.JSON(500, gin.H{"code": 500, "msg": "Failed to create proxy request"})
c.Abort()
return
}
// 复制请求头
req.Header = c.Request.Header.Clone()
// 覆盖认证授权 Header 确保子节点鉴权通过
if node.Token != "" {
req.Header.Set("Authorization", "Bearer "+node.Token)
}
// 移除 Cookie 头部防干扰
req.Header.Del("Cookie")
req.Header.Set("X-Tunnel-Proxy", "true")
resp, err := travelProxyClient.Do(req)
if err != nil {
// 如果是客户端自己主动取消了请求(例如连续刷新、关闭网页等),直接退出,不应视为子节点离线而执行退回主节点的操作
if c.Request.Context().Err() == context.Canceled || strings.Contains(err.Error(), "context canceled") {
c.Abort()
return
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
c.Abort()
return
}
c.JSON(502, gin.H{"code": 502, "msg": "无法连接至目标子节点: " + err.Error()})
c.Abort()
return
}
defer resp.Body.Close()
// 复制响应头
for k, v := range resp.Header {
for _, vv := range v {
c.Writer.Header().Add(k, vv)
}
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
c.Abort()
}
}