Files
backend/internal/handler/gorse_handler.go
lafay b0f209fdf8
All checks were successful
Build Backend / build (push) Successful in 4m47s
Build Backend / build-docker (push) Successful in 2m22s
fix(security): security audit fixes for API vulnerabilities
Security fixes:
- Fix sensitive data exposure: UserResponse no longer contains email/phone
- Add UserSelfResponse for authenticated user's own data
- Protect Gorse endpoints with admin role requirement
- Add rate limiting (10 req/min) to auth endpoints
- Fix CORS configuration to use allowed origins list
- Add pagination parameter validation (max 100 per page)

Changes:
- dto/dto.go: Add UserSelfResponse type
- dto/user_converter.go: Add ConvertUserToSelfResponse
- handler/user_handler.go: Use secure response types
- middleware/cors.go: Implement origin whitelist
- middleware/ratelimit.go: Enhance rate limiter
- router/router.go: Add auth rate limit, protect Gorse
- service/admin_*.go: Use ConvertUserToResponse
2026-03-19 13:49:51 +08:00

256 lines
6.3 KiB
Go

package handler
import (
"context"
"strings"
"time"
"carrot_bbs/internal/config"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/gorse"
"carrot_bbs/internal/pkg/response"
"github.com/gin-gonic/gin"
gorseio "github.com/gorse-io/gorse-go"
"go.uber.org/zap"
"gorm.io/gorm"
)
// GorseHandler Gorse推荐处理器
type GorseHandler struct {
importPassword string
gorseConfig config.GorseConfig
db *gorm.DB
}
// NewGorseHandler 创建Gorse处理器
func NewGorseHandler(cfg config.GorseConfig, db *gorm.DB) *GorseHandler {
return &GorseHandler{
importPassword: cfg.ImportPassword,
gorseConfig: cfg,
db: db,
}
}
// ImportRequest 导入请求
type ImportRequest struct {
Password string `json:"password"`
}
// ImportData 导入数据到Gorse
// POST /api/v1/gorse/import
func (h *GorseHandler) ImportData(c *gin.Context) {
// 验证密码
if h.importPassword == "" {
response.BadRequest(c, "Gorse import is disabled")
return
}
var req ImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request body")
return
}
if req.Password != h.importPassword {
response.Unauthorized(c, "invalid password")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
stats, err := h.importAllData(ctx)
if err != nil {
zap.L().Error("gorse import failed", zap.Error(err))
response.InternalServerError(c, "gorse import failed: "+err.Error())
return
}
response.Success(c, gin.H{
"message": "import completed",
"status": "done",
"stats": stats,
})
}
// GetStatus 获取Gorse状态
// GET /api/v1/gorse/status
func (h *GorseHandler) GetStatus(c *gin.Context) {
hasPassword := h.importPassword != ""
response.Success(c, gin.H{
"enabled": h.gorseConfig.Enabled,
"has_password": hasPassword,
})
}
func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
gorseClient := gorseio.NewGorseClient(h.gorseConfig.Address, h.gorseConfig.APIKey)
gorse.InitEmbeddingWithConfig(h.gorseConfig.EmbeddingAPIKey, h.gorseConfig.EmbeddingURL, h.gorseConfig.EmbeddingModel)
stats := gin.H{
"items": 0,
"users": 0,
"likes": 0,
"favorites": 0,
"comments": 0,
}
// 导入帖子
var posts []model.Post
if err := h.db.Find(&posts).Error; err != nil {
return nil, err
}
for _, post := range posts {
embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content))
if err != nil {
zap.L().Warn("get embedding failed for post",
zap.String("postID", post.ID),
zap.Error(err),
)
embedding = make([]float64, 1024)
}
_, err = gorseClient.InsertItem(ctx, gorseio.Item{
ItemId: post.ID,
IsHidden: post.DeletedAt.Valid,
Categories: buildPostCategories(&post),
Comment: post.Title,
Timestamp: post.CreatedAt.UTC().Truncate(time.Second),
Labels: map[string]any{
"embedding": embedding,
},
})
if err != nil {
zap.L().Warn("import item failed",
zap.String("postID", post.ID),
zap.Error(err),
)
continue
}
stats["items"] = stats["items"].(int) + 1
}
// 导入用户
var users []model.User
if err := h.db.Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
_, err := gorseClient.InsertUser(ctx, gorseio.User{
UserId: user.ID,
Labels: map[string]any{
"posts_count": user.PostsCount,
"followers_count": user.FollowersCount,
"following_count": user.FollowingCount,
},
Comment: user.Nickname,
})
if err != nil {
zap.L().Warn("import user failed",
zap.String("userID", user.ID),
zap.Error(err),
)
continue
}
stats["users"] = stats["users"].(int) + 1
}
// 导入点赞
var likes []model.PostLike
if err := h.db.Find(&likes).Error; err != nil {
return nil, err
}
for _, like := range likes {
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeLike),
UserId: like.UserID,
ItemId: like.PostID,
Timestamp: like.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import like failed",
zap.String("userID", like.UserID),
zap.String("postID", like.PostID),
zap.Error(err),
)
continue
}
stats["likes"] = stats["likes"].(int) + 1
}
// 导入收藏
var favorites []model.Favorite
if err := h.db.Find(&favorites).Error; err != nil {
return nil, err
}
for _, fav := range favorites {
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeStar),
UserId: fav.UserID,
ItemId: fav.PostID,
Timestamp: fav.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import favorite failed",
zap.String("userID", fav.UserID),
zap.String("postID", fav.PostID),
zap.Error(err),
)
continue
}
stats["favorites"] = stats["favorites"].(int) + 1
}
// 导入评论(按用户-帖子去重)
var comments []model.Comment
if err := h.db.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil {
return nil, err
}
seen := make(map[string]struct{})
for _, cm := range comments {
key := cm.UserID + ":" + cm.PostID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeComment),
UserId: cm.UserID,
ItemId: cm.PostID,
Timestamp: cm.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import comment failed",
zap.String("userID", cm.UserID),
zap.String("postID", cm.PostID),
zap.Error(err),
)
continue
}
stats["comments"] = stats["comments"].(int) + 1
}
return stats, nil
}
func buildPostCategories(post *model.Post) []string {
var categories []string
if post.ViewsCount > 1000 {
categories = append(categories, "hot_high")
} else if post.ViewsCount > 100 {
categories = append(categories, "hot_medium")
}
if post.LikesCount > 100 {
categories = append(categories, "likes_100+")
} else if post.LikesCount > 10 {
categories = append(categories, "likes_10+")
}
age := time.Since(post.CreatedAt)
if age < 24*time.Hour {
categories = append(categories, "today")
} else if age < 7*24*time.Hour {
categories = append(categories, "this_week")
}
return categories
}