fix: 修复头像审核旧图裂开问题 + CSP 配置修复
- 头像提交审核不再删除旧文件,审核通过后才删除旧头像文件 - 头像审核被拒时删除已上传的新头像文件,保留旧头像 - CSP style-src 移除 nonce 以启用 unsafe-inline(JS 动态样式需要) - CSP script-src 移除 nonce 改用 unsafe-inline + unsafe-eval(Vditor 编辑器和动态样式需要)
This commit is contained in:
@ -32,8 +32,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'nonce-"+nonce+"'; "+
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
||||||
"style-src 'self' 'nonce-"+nonce+"'; "+
|
"style-src 'self' 'unsafe-inline'; "+
|
||||||
"img-src 'self' data: blob:; "+
|
"img-src 'self' data: blob:; "+
|
||||||
"connect-src 'self'")
|
"connect-src 'self'")
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
|
|
||||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||||
auditService.WithEnergyRefundService(energyService)
|
auditService.WithEnergyRefundService(energyService)
|
||||||
|
auditService.WithAvatarFileCleaner(avatarSvc)
|
||||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||||
|
|
||||||
messageController := controller.NewMessageController(notificationService)
|
messageController := controller.NewMessageController(notificationService)
|
||||||
|
|||||||
@ -67,6 +67,11 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
|
|||||||
}
|
}
|
||||||
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 头像审核被拒 → 删除提交的新头像文件
|
||||||
|
if submission.AuditType == model.AuditTypeAvatar && s.avatarCleaner != nil {
|
||||||
|
_ = s.avatarCleaner.DeleteAvatarFile(submission.NewValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.auditRepo.Update(submission)
|
return s.auditRepo.Update(submission)
|
||||||
}
|
}
|
||||||
@ -90,7 +95,16 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
|||||||
}
|
}
|
||||||
user.Username = submission.NewValue
|
user.Username = submission.NewValue
|
||||||
case model.AuditTypeAvatar:
|
case model.AuditTypeAvatar:
|
||||||
|
oldAvatar := user.Avatar
|
||||||
user.Avatar = submission.NewValue
|
user.Avatar = submission.NewValue
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 审核通过,删除旧头像文件
|
||||||
|
if s.avatarCleaner != nil {
|
||||||
|
_ = s.avatarCleaner.DeleteAvatarFile(oldAvatar)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
case model.AuditTypeBio:
|
case model.AuditTypeBio:
|
||||||
user.Bio = submission.NewValue
|
user.Bio = submission.NewValue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,12 @@ type energyRenameRefunder interface {
|
|||||||
RefundOnRenameReject(userID uint) error
|
RefundOnRenameReject(userID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口(ISP)
|
||||||
|
type avatarFileCleaner interface {
|
||||||
|
CleanOldAvatars(userID uint)
|
||||||
|
DeleteAvatarFile(url string) error
|
||||||
|
}
|
||||||
|
|
||||||
// AuditService 审核业务逻辑
|
// AuditService 审核业务逻辑
|
||||||
type AuditService struct {
|
type AuditService struct {
|
||||||
auditRepo auditRepo
|
auditRepo auditRepo
|
||||||
@ -57,6 +63,7 @@ type AuditService struct {
|
|||||||
notifier auditNotifier
|
notifier auditNotifier
|
||||||
levelSvc auditTaskCompleter
|
levelSvc auditTaskCompleter
|
||||||
energyRefundSvc energyRenameRefunder
|
energyRefundSvc energyRenameRefunder
|
||||||
|
avatarCleaner avatarFileCleaner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuditService 构造函数
|
// NewAuditService 构造函数
|
||||||
@ -77,6 +84,12 @@ func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditS
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAvatarFileCleaner 链式注入头像文件清理服务
|
||||||
|
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
|
||||||
|
s.avatarCleaner = svc
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// ShouldAudit 判断指定用户是否需要走审核流程
|
// ShouldAudit 判断指定用户是否需要走审核流程
|
||||||
// 规则:admin 及以上角色免审
|
// 规则:admin 及以上角色免审
|
||||||
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
||||||
|
|||||||
@ -57,6 +57,9 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 直接更新场景:删除旧头像文件,仅保留新文件
|
||||||
|
s.CleanOldAvatars(userID)
|
||||||
|
|
||||||
user, err := s.userRepo.FindByID(userID)
|
user, err := s.userRepo.FindByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("用户不存在")
|
return "", fmt.Errorf("用户不存在")
|
||||||
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
|||||||
return "", fmt.Errorf("创建存储目录失败")
|
return "", fmt.Errorf("创建存储目录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 删除该用户的旧头像,仅保留最新
|
// 8. 原子写入:先写临时文件,再 rename 到最终路径
|
||||||
s.cleanOldAvatars(userID)
|
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
|
||||||
|
|
||||||
// 9. 原子写入:先写临时文件,再 rename 到最终路径
|
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
||||||
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
||||||
@ -153,8 +154,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
|||||||
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
|
||||||
func (s *AvatarService) cleanOldAvatars(userID uint) {
|
func (s *AvatarService) CleanOldAvatars(userID uint) {
|
||||||
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
||||||
entries, err := os.ReadDir(s.storageDir)
|
entries, err := os.ReadDir(s.storageDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -167,6 +168,19 @@ func (s *AvatarService) cleanOldAvatars(userID uint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
|
||||||
|
func (s *AvatarService) DeleteAvatarFile(url string) error {
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filename := filepath.Base(url)
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(s.storageDir, filename)
|
||||||
|
return os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
// subImage 从原图中裁切指定正方形区域
|
// subImage 从原图中裁切指定正方形区域
|
||||||
func subImage(img image.Image, x, y, size int) image.Image {
|
func subImage(img image.Image, x, y, size int) image.Image {
|
||||||
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
|||||||
Reference in New Issue
Block a user