初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

156
middleware/auth.go Normal file
View File

@@ -0,0 +1,156 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/jiang13/forum/model"
"github.com/jiang13/forum/service"
)
const (
CtxUserID = "user_id"
CtxUsername = "username"
CtxRole = "role"
CookieName = "jiang13_token"
)
type AuthMiddleware struct {
auth *service.AuthService
}
func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware {
return &AuthMiddleware{auth: auth}
}
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token != "" {
if claims, err := m.auth.ParseToken(token); err == nil {
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUsername, claims.Username)
c.Set(CtxRole, claims.Role)
}
}
c.Next()
}
}
// RequireAuth 必须登录
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token == "" {
respondAuthRequired(c)
c.Abort()
return
}
claims, err := m.auth.ParseToken(token)
if err != nil {
respondAuthExpired(c)
c.Abort()
return
}
// 检查禁言
var user model.User
if err := model.DB.First(&user, claims.UserID).Error; err != nil || user.Banned {
respondBanned(c)
c.Abort()
return
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUsername, claims.Username)
c.Set(CtxRole, claims.Role)
c.Next()
}
}
// RequireAdmin 必须管理员
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get(CtxRole)
if !exists || role != model.RoleAdmin {
if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
} else {
c.Redirect(http.StatusFound, "/admin/login")
}
c.Abort()
return
}
c.Next()
}
}
func extractToken(c *gin.Context) string {
if auth := c.GetHeader("Authorization"); auth != "" {
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
}
if token, err := c.Cookie(CookieName); err == nil {
return token
}
return c.Query("token")
}
func isAPI(c *gin.Context) bool {
p := c.Request.URL.Path
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/admin/api/")
}
func adminLoginPath(c *gin.Context) string {
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
return "/admin/login"
}
return "/login"
}
func respondAuthRequired(c *gin.Context) {
if isAPI(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
c.Redirect(http.StatusFound, adminLoginPath(c))
}
func respondAuthExpired(c *gin.Context) {
c.SetCookie(CookieName, "", -1, "/", "", false, true)
if isAPI(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
return
}
c.Redirect(http.StatusFound, adminLoginPath(c))
}
func respondBanned(c *gin.Context) {
if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
return
}
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
c.Redirect(http.StatusFound, "/admin/login?banned=1")
return
}
c.Redirect(http.StatusFound, "/login?banned=1")
}
// RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.ClientIP() + ":" + action
if uid, ok := c.Get(CtxUserID); ok {
key = fmt.Sprintf("%s:%d", action, uid.(uint))
}
if !limiter.Allow(key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
c.Abort()
return
}
c.Next()
}
}

39
middleware/presence.go Normal file
View File

@@ -0,0 +1,39 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
"github.com/jiang13/forum/service"
)
const VisitorCookieName = "j13_vid"
// PresenceMiddleware 记录当前请求的浏览活跃(会员或游客)
func PresenceMiddleware(online *service.OnlineService) gin.HandlerFunc {
return func(c *gin.Context) {
if uid, ok := c.Get(CtxUserID); ok {
if id, ok := uid.(uint); ok && id > 0 {
online.Ping(id)
c.Next()
return
}
}
vid, err := c.Cookie(VisitorCookieName)
if err != nil || vid == "" {
vid = newVisitorID()
c.SetCookie(VisitorCookieName, vid, 86400, "/", "", false, true)
}
online.PingGuest(vid)
c.Next()
}
}
func newVisitorID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte("fallback-visitor-id"))
}
return hex.EncodeToString(b)
}