chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Admin 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
func Admin() gin.HandlerFunc {
return AdminMiddleware()
}
// AdminMiddleware 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
userMap, ok := user.(map[string]interface{})
if !ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
return
}
role, ok := userMap["role"].(string)
if !ok || role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
c.Next()
}
}
+105
View File
@@ -0,0 +1,105 @@
package middleware
import (
"net/http"
"strings"
"silk-server-go/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// JWTClaims JWT 负载,与 NestJS 签发格式一致
type JWTClaims struct {
Username string `json:"username"`
Role string `json:"role"`
TokenType string `json:"tokenType,omitempty"` // access | refresh,空值视为 access(兼容旧令牌)
jwt.RegisteredClaims
}
// 白名单路径,无需鉴权
var whitelist = map[string]bool{
"/api/v1/health": true,
"/health": true,
"/api/health": true,
"/api/v1/auth/login": true,
"/auth/login": true,
"/api/v1/auth/register": true,
"/auth/register": true,
"/api/v1/auth/refresh": true,
"/auth/refresh": true,
"/api/v1/video/clips/internal": true,
"/api/v1/video/recordings/internal/end": true,
}
// Auth JWT 鉴权中间件
func Auth(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// 白名单路径跳过鉴权
if whitelist[path] {
c.Next()
return
}
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
c.Next()
return
}
// 直播流转码代理接口
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
c.Next()
return
}
// 解析 Authorization: Bearer <token>
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未提供认证信息"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"})
return
}
tokenStr := parts[1]
// 用 JWTSecret 验证 token,仅允许 HS256 算法
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
return
}
// 校验令牌是否已被登出吊销
if IsRevoked(claims) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
return
}
// 拒绝使用 refresh 令牌访问业务接口
if claims.TokenType == "refresh" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
return
}
// 将用户信息存入 context
c.Set("user", map[string]interface{}{
"sub": claims.Subject,
"username": claims.Username,
"role": claims.Role,
})
c.Next()
}
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件,限制允许的来源、方法和头
func CORS() gin.HandlerFunc {
// 允许的来源:同源、本地开发、Tailscale 网段、局域网
allowedOrigins := []string{
"http://localhost:5174",
"http://localhost:3000",
"http://127.0.0.1:5174",
"http://127.0.0.1:3000",
}
isAllowed := func(origin string) bool {
for _, o := range allowedOrigins {
if origin == o {
return true
}
}
// 允许 Tailscale 100.x.x.x 和局域网 192.168.x.x 访问
if strings.HasPrefix(origin, "http://100.") || strings.HasPrefix(origin, "http://192.168.") ||
strings.HasPrefix(origin, "http://113.") || strings.HasPrefix(origin, "http://115.") {
return true
}
return false
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && isAllowed(origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin")
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+29
View File
@@ -0,0 +1,29 @@
package middleware
import (
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// Logger 请求日志中间件,记录方法、路径、状态码、耗时
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
slog.Info("请求",
"method", c.Request.Method,
"path", path,
"status", status,
"latency", latency.String(),
"ip", c.ClientIP(),
)
}
}
+109
View File
@@ -0,0 +1,109 @@
package middleware
import (
"net/http"
"sync"
"time"
"silk-server-go/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// rolePermCache 角色-权限码缓存,避免每次请求查库
var (
rolePermCache = make(map[string]map[string]bool)
rolePermCacheMu sync.RWMutex
rolePermCacheTime time.Time
)
const rolePermCacheTTL = 5 * time.Minute
// loadRolePermissions 从数据库加载所有角色-权限映射到缓存
func loadRolePermissions(db *gorm.DB) map[string]map[string]bool {
result := make(map[string]map[string]bool)
var rows []struct {
Role string `gorm:"column:role"`
Code string `gorm:"column:code"`
}
db.Table("role_permissions").
Select("role_permissions.role AS role, permissions.code AS code").
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
Scan(&rows)
for _, r := range rows {
if result[r.Role] == nil {
result[r.Role] = make(map[string]bool)
}
result[r.Role][r.Code] = true
}
return result
}
// getRolePermissions 获取缓存的角色权限映射(TTL 5 分钟)
func getRolePermissions(db *gorm.DB) map[string]map[string]bool {
rolePermCacheMu.RLock()
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
cached := rolePermCache
rolePermCacheMu.RUnlock()
return cached
}
rolePermCacheMu.RUnlock()
rolePermCacheMu.Lock()
defer rolePermCacheMu.Unlock()
// 双重检查
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
return rolePermCache
}
rolePermCache = loadRolePermissions(db)
rolePermCacheTime = time.Now()
return rolePermCache
}
// InvalidateRolePermCache 使角色权限缓存失效(角色权限变更时调用)
func InvalidateRolePermCache() {
rolePermCacheMu.Lock()
defer rolePermCacheMu.Unlock()
rolePermCache = make(map[string]map[string]bool)
rolePermCacheTime = time.Time{}
}
// hasPermission 检查角色是否拥有指定权限码
func hasPermission(db *gorm.DB, role, code string) bool {
perms := getRolePermissions(db)
rolePerms, ok := perms[role]
if !ok {
return false
}
return rolePerms[code]
}
// RequirePermission 返回一个校验指定权限码的中间件
func RequirePermission(db *gorm.DB, code string) gin.HandlerFunc {
return func(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
userMap, ok := userVal.(map[string]interface{})
if !ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
return
}
role, _ := userMap["role"].(string)
if role == model.RoleAdmin {
// admin 拥有全部权限,直接放行
c.Next()
return
}
if !hasPermission(db, role, code) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足,需要:" + code})
return
}
c.Next()
}
}
+122
View File
@@ -0,0 +1,122 @@
package middleware
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// loginAttempt 登录失败计数(按 IP + 用户名维度)
type loginAttempt struct {
failures int
lockUntil time.Time
lastFail time.Time
}
type loginLimiter struct {
mu sync.Mutex
seen map[string]*loginAttempt
}
const (
maxFailures = 5 // 连续失败 5 次后锁定
lockDuration = 15 * time.Minute
failureWindow = 10 * time.Minute // 失败计数窗口
cleanupInterval = 5 * time.Minute
)
var defaultLoginLimiter = newLoginLimiter()
func newLoginLimiter() *loginLimiter {
l := &loginLimiter{seen: make(map[string]*loginAttempt)}
go l.cleanupLoop()
return l
}
func (l *loginLimiter) cleanupLoop() {
t := time.NewTicker(cleanupInterval)
defer t.Stop()
for range t.C {
l.mu.Lock()
now := time.Now()
for k, v := range l.seen {
if now.After(v.lockUntil) && now.Sub(v.lastFail) > failureWindow {
delete(l.seen, k)
}
}
l.mu.Unlock()
}
}
// key = ip + "|" + username(小写)
func limiterKey(c *gin.Context, username string) string {
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
}
// checkLock 返回是否被锁定及剩余锁定时间
func (l *loginLimiter) checkLock(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.seen[key]
if !ok {
return false, 0
}
if time.Now().Before(a.lockUntil) {
return true, time.Until(a.lockUntil)
}
return false, 0
}
// recordFailure 记录一次失败,达到阈值则锁定
func (l *loginLimiter) recordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.seen[key]
if !ok {
a = &loginAttempt{}
l.seen[key] = a
}
now := time.Now()
// 窗口外重置
if now.Sub(a.lastFail) > failureWindow {
a.failures = 0
}
a.failures++
a.lastFail = now
if a.failures >= maxFailures {
a.lockUntil = now.Add(lockDuration)
}
}
// recordSuccess 登录成功后清空计数
func (l *loginLimiter) recordSuccess(key string) {
l.mu.Lock()
delete(l.seen, key)
l.mu.Unlock()
}
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
func CheckLoginLock(c *gin.Context, username string) bool {
key := limiterKey(c, username)
if locked, remain := defaultLoginLimiter.checkLock(key); locked {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "登录尝试过多,已锁定,请稍后再试",
"retry": int(remain.Minutes()) + 1,
})
return true
}
return false
}
// RecordLoginFail 记录登录失败
func RecordLoginFail(c *gin.Context, username string) {
defaultLoginLimiter.recordFailure(limiterKey(c, username))
}
// RecordLoginSuccess 登录成功后清空计数
func RecordLoginSuccess(c *gin.Context, username string) {
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
}
+20
View File
@@ -0,0 +1,20 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// SecurityHeaders 统一补齐 HTTP 安全响应头,缓解点击劫持、MIME 嗅探等风险
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
// 限制内联脚本/样式以缓解 XSS;允许同源与必要的外部资源
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'")
// 仅在内网 HTTPS 网关后运行时生效;HTTP 下浏览器会忽略
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Next()
}
}
@@ -0,0 +1,64 @@
package middleware
import (
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// tokenBlacklist 登出令牌黑名单(内存版,进程重启后失效,令牌自然过期兜底)
type tokenBlacklist struct {
mu sync.RWMutex
revoked map[string]time.Time // tokenID(jti) -> 过期时间
}
var defaultBlacklist = &tokenBlacklist{revoked: make(map[string]time.Time)}
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
id := tokenIdentifier(claims)
defaultBlacklist.mu.Lock()
defaultBlacklist.revoked[id] = exp
defaultBlacklist.mu.Unlock()
}
// IsRevoked 判断令牌是否已被吊销
func IsRevoked(claims *JWTClaims) bool {
id := tokenIdentifier(claims)
defaultBlacklist.mu.RLock()
exp, ok := defaultBlacklist.revoked[id]
defaultBlacklist.mu.RUnlock()
if !ok {
return false
}
// 已过期的黑名单项自动清理
if time.Now().After(exp) {
defaultBlacklist.mu.Lock()
delete(defaultBlacklist.revoked, id)
defaultBlacklist.mu.Unlock()
return false
}
return true
}
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
func ExtractClaims(tokenStr string, secret string) (*JWTClaims, *jwt.Token, error) {
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
return claims, token, err
}
// tokenIdentifier 返回令牌唯一标识:优先 jti,否则用 subject+签发时间
func tokenIdentifier(claims *JWTClaims) string {
if claims.ID != "" {
return claims.ID
}
iat := ""
if claims.IssuedAt != nil {
iat = claims.IssuedAt.Format(time.RFC3339Nano)
}
return claims.Subject + "|" + iat
}