Compare commits
5 Commits
202b3963f8
...
b8e9af001f
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e9af001f | |||
| b3374dccf4 | |||
| 2ecd1fef1e | |||
| 9ad7b5c0df | |||
| 1f7a223768 |
@@ -32,6 +32,14 @@ func NewAvatarHandler(userRepo avatarUserRepository) *AvatarHandler {
|
||||
return &AvatarHandler{userRepo: userRepo}
|
||||
}
|
||||
|
||||
const (
|
||||
maxAvatarSize = 5 * 1024 * 1024 // 5MB
|
||||
magicBytesBufSize = 512
|
||||
avatarTokenLen = 8
|
||||
dirPerm = 0o755
|
||||
filePerm = 0o644
|
||||
)
|
||||
|
||||
// generateSecureToken generates a secure random token
|
||||
func generateSecureToken(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
@@ -93,7 +101,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if file.Size > 5*1024*1024 {
|
||||
if file.Size > maxAvatarSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "file size exceeds 5MB limit"})
|
||||
return
|
||||
}
|
||||
@@ -115,7 +123,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
defer src.Close()
|
||||
|
||||
// Validate Magic Bytes to detect actual file type (prevents file extension spoofing)
|
||||
buf := make([]byte, 512)
|
||||
buf := make([]byte, magicBytesBufSize)
|
||||
n, err := src.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "failed to read file"})
|
||||
@@ -140,11 +148,11 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(8), ext)
|
||||
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(avatarTokenLen), ext)
|
||||
uploadDir := "./uploads/avatars"
|
||||
|
||||
// Create upload directory if not exists
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(uploadDir, dirPerm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to create upload directory"})
|
||||
return
|
||||
}
|
||||
@@ -156,7 +164,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
||||
if err := os.WriteFile(dstPath, data, filePerm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
|
||||
return
|
||||
}
|
||||
|
||||
21
internal/api/handler/common.go
Normal file
21
internal/api/handler/common.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/pagination"
|
||||
)
|
||||
|
||||
// parsePageAndSize extracts and validates page & page_size from query parameters.
|
||||
// Returns page (>=1) and pageSize (clamped to [1, MaxPageSize]).
|
||||
func parsePageAndSize(c *gin.Context) (page, pageSize int) {
|
||||
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(pagination.DefaultPageSize)))
|
||||
pageSize = pagination.ClampPageSize(pageSize)
|
||||
return
|
||||
}
|
||||
@@ -87,11 +87,7 @@ func (h *DeviceHandler) GetMyDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
page, pageSize := parsePageAndSize(c)
|
||||
|
||||
devices, total, err := h.deviceService.GetUserDevices(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
@@ -315,11 +311,7 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
page, pageSize := parsePageAndSize(c)
|
||||
|
||||
devices, total, err := h.deviceService.GetUserDevices(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -42,11 +41,7 @@ func (h *LogHandler) GetMyLoginLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
page, pageSize := parsePageAndSize(c)
|
||||
|
||||
logs, total, err := h.loginLogService.GetMyLoginLogs(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
@@ -84,11 +79,7 @@ func (h *LogHandler) GetMyOperationLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
page, pageSize := parsePageAndSize(c)
|
||||
|
||||
logs, total, err := h.operationLogService.GetMyOperationLogs(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/user-management-system/internal/api/middleware"
|
||||
"github.com/user-management-system/internal/auth"
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/pagination"
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
@@ -115,7 +116,7 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
|
||||
// Fallback to legacy offset-based pagination
|
||||
offset, _ := strconv.ParseInt(c.DefaultQuery("offset", "0"), 10, 64)
|
||||
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "20"), 10, 64)
|
||||
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", strconv.Itoa(pagination.DefaultPageSize)), 10, 64)
|
||||
|
||||
users, total, err := h.userService.List(c.Request.Context(), int(offset), int(limit))
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/pagination"
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
@@ -65,14 +66,7 @@ func (h *WebhookHandler) CreateWebhook(c *gin.Context) {
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/webhooks [get]
|
||||
func (h *WebhookHandler) ListWebhooks(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
page, pageSize := parsePageAndSize(c)
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
@@ -178,10 +172,8 @@ func (h *WebhookHandler) GetWebhookDeliveries(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(pagination.DefaultPageSize)))
|
||||
limit = pagination.ClampPageSize(limit)
|
||||
|
||||
deliveries, err := h.webhookService.GetWebhookDeliveries(c.Request.Context(), id, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -362,10 +362,10 @@ func (r *UserRepository) AdvancedSearch(ctx context.Context, filter *AdvancedFil
|
||||
// 分页
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
limit = pagination.DefaultPageSize
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
if limit > pagination.MaxPageSize {
|
||||
limit = pagination.MaxPageSize
|
||||
}
|
||||
query = query.Offset(filter.Offset).Limit(limit)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
defaultTOTPChallengeTTL = 5 * time.Minute
|
||||
defaultPasswordMinLen = 8
|
||||
refreshTokenRetryGrace = 10 * time.Second
|
||||
defaultBETimeout = 5 * time.Second // best-effort 后台操作默认超时
|
||||
MaxUsernameAttempts = 100 // 最大尝试次数(P1性能优化:减少循环查询)
|
||||
MaxUsernameLength = 40 // 用户名最大长度
|
||||
)
|
||||
@@ -553,7 +554,7 @@ func (s *AuthService) writeLoginLog(
|
||||
log.Printf("auth: write login log panic recovered, user_id=%v login_type=%d err=%v", userID, loginType, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
if err := s.loginLogRepo.Create(bgCtx, loginRecord); err != nil {
|
||||
log.Printf("auth: write login log failed, user_id=%v login_type=%d err=%v", userID, loginType, err)
|
||||
@@ -634,7 +635,7 @@ func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64
|
||||
log.Printf("auth: register device panic recovered, user_id=%d device_id=%s err=%v", userID, req.DeviceID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
_, _ = s.deviceService.CreateDevice(bgCtx, userID, createReq)
|
||||
}()
|
||||
|
||||
@@ -95,7 +95,7 @@ func (s *AuthService) bestEffortUpdateLastLogin(ctx context.Context, userID int6
|
||||
log.Printf("auth: update last login panic recovered, source=%s user_id=%d err=%v", source, userID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
if err := s.userRepo.UpdateLastLogin(bgCtx, userID, ip); err != nil {
|
||||
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)
|
||||
|
||||
@@ -223,7 +223,7 @@ func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page,
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
pageSize = pagination.DefaultPageSize
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
@@ -275,7 +275,7 @@ func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
pageSize = pagination.DefaultPageSize
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
@@ -228,7 +228,7 @@ func (s *LoginLogService) GetMyLoginLogs(ctx context.Context, userID int64, page
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
pageSize = pagination.DefaultPageSize
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
return s.loginLogRepo.ListByUserID(ctx, userID, offset, pageSize)
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *OperationLogService) GetMyOperationLogs(ctx context.Context, userID int
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
pageSize = pagination.DefaultPageSize
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
return s.operationLogRepo.ListByUserID(ctx, userID, offset, pageSize)
|
||||
|
||||
@@ -299,7 +299,7 @@ func (s *PasswordResetService) doResetPassword(ctx context.Context, user *domain
|
||||
if s.passwordHistoryRepo != nil {
|
||||
// #nosec G118 - 使用带超时的独立 context,防止 DB 写入无限等待
|
||||
go func() { // #nosec G118
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
|
||||
UserID: user.ID,
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
if s.passwordHistoryRepo != nil {
|
||||
// #nosec G118 - 使用带超时的独立 context(不能使用请求 ctx,该 goroutine 在请求完成后仍可能运行)
|
||||
go func(hashedPw string) { // #nosec G118
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
|
||||
UserID: userID,
|
||||
@@ -199,7 +199,7 @@ func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, n
|
||||
log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
|
||||
UserID: userID,
|
||||
|
||||
@@ -295,7 +295,7 @@ func (s *WebhookService) recordDelivery(task *deliveryTask, statusCode int, body
|
||||
delivery.DeliveredAt = &now
|
||||
}
|
||||
// 使用带超时的独立 context,防止 DB 写入无限等待
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
|
||||
defer cancel()
|
||||
_ = s.repo.CreateDelivery(writeCtx, delivery)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user