安全: 修复凭据、遥测与账号数据清理风险

This commit is contained in:
tamakyi
2026-11-04 14:38:19 +08:00
parent c633789a92
commit 3e7719f200
19 changed files with 305 additions and 3378 deletions
+2
View File
@@ -68,6 +68,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newSafeLogger(),
SkipDefaultTransaction: true,
})
if err != nil {
@@ -155,6 +156,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
otelgorm.WithoutQueryVariables(),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm/logger"
)
type safeLogger struct {
logger.Config
}
func newSafeLogger() logger.Interface {
return &safeLogger{Config: logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
}}
}
func (l *safeLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *l
newLogger.LogLevel = level
return &newLogger
}
func (l *safeLogger) Info(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Info {
logrus.WithContext(ctx).WithField("component", "gorm").Info("数据库信息事件")
}
}
func (l *safeLogger) Warn(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Warn {
logrus.WithContext(ctx).WithField("component", "gorm").Warn("数据库警告事件")
}
}
func (l *safeLogger) Error(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Error {
logrus.WithContext(ctx).WithField("component", "gorm").Error("数据库错误事件")
}
}
func (l *safeLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= logger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && l.LogLevel >= logger.Error && (!errors.Is(err, logger.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).WithFields(logrus.Fields{
"error_type": fmt.Sprintf("%T", err),
"record_not_found": errors.Is(err, logger.ErrRecordNotFound),
}).Error("数据库操作失败")
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= logger.Warn:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).
WithField("slow_threshold", l.SlowThreshold.String()).
Warn("数据库慢查询")
case l.LogLevel >= logger.Info:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).Info("数据库操作完成")
}
}
func (l *safeLogger) traceEntry(ctx context.Context, elapsed time.Duration, rows int64) *logrus.Entry {
return logrus.WithContext(ctx).WithFields(logrus.Fields{
"component": "gorm",
"elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
"rows_affected": rows,
})
}
+56 -8
View File
@@ -1011,7 +1011,8 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
}
// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、
// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。
// 提问图片元数据及关联、提问、投票及其子表、访问统计、拉黑关系、功能开关。
// **不含用户行本身**(由调用方决定是软删除还是物理删除),也不删除审计/处置记录。
//
// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引
// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。
@@ -1027,18 +1028,18 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete follow ups of user")
}
// 消息图片必须先于消息删除,否则消息子查询将无法再匹配待删图片。
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of user")
}
// 消息图片
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 私聊会话
if err := tx.WithContext(ctx).Unscoped().
@@ -1047,7 +1048,25 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete conversations of user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
// 提问图片元数据:删除本用户上传的孤立图片,以及仅链接到本用户提问的图片。
// 仍被其他提问引用的图片必须保留;这里只清数据库元数据,不操作对象存储。
if err := tx.WithContext(ctx).Unscoped().
Where(`(
uploader_user_id = ?
OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)
) AND NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
AND shared_links.question_id NOT IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID, userID).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images of user")
}
// 提问-图片关联
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
@@ -1061,6 +1080,35 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete questions of user")
}
// 投票子表必须先于投票主表删除;只处理该用户拥有的投票,不按参与者身份扩大范围。
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&Vote{}).Error; err != nil {
return errors.Wrap(err, "delete votes of user")
}
// 提问箱访问统计:user_id 明确指向箱主,不属于站方审计/处置记录。
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&BoxView{}).Error; err != nil {
return errors.Wrap(err, "delete box views of user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ? OR blocked_id = ?", userID, userID).
+98
View File
@@ -552,6 +552,104 @@ func TestDeactivatePurgesUser(t *testing.T) {
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestDeactivatePurgesAllOwnedContent(t *testing.T) {
db, _ := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
createUser := func(email, domain string) *User {
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := usersStore.GetByEmail(ctx, email)
require.Nil(t, err)
return user
}
owner := createUser("[email protected]", "purge-all")
other := createUser("[email protected]", "purge-other")
question, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.Nil(t, err)
otherQuestion, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.Nil(t, err)
images := NewUploadImagesStore(db)
ownedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp",
})
require.Nil(t, err)
sharedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp",
})
require.Nil(t, err)
require.Nil(t, db.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: sharedImage.ID, QuestionID: otherQuestion.ID,
}).Error)
orphanImage := &UploadImage{UploaderUserID: owner.ID, Key: "orphan.webp"}
require.Nil(t, db.WithContext(ctx).Create(orphanImage).Error)
otherImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: otherQuestion.ID, UploaderUserID: other.ID, Key: "other.webp",
})
require.Nil(t, err)
conversations := NewConversationStore(db)
conversation, message, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.Nil(t, err)
_, err = NewMessageImageStore(db).Create(ctx, CreateMessageImageOptions{
MessageID: message.ID, URL: "message.webp", FileName: "message.webp",
})
require.Nil(t, err)
vote, err := NewVotesStore(db).Create(ctx, CreateVoteOptions{
UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm,
})
require.Nil(t, err)
option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.Nil(t, err)
_, err = NewVoteFormFieldsStore(db).Create(ctx, CreateVoteFormFieldOptions{
VoteID: vote.ID, FieldType: VoteFormFieldTypeText, Label: "field",
})
require.Nil(t, err)
_, err = NewVoteSubmissionsStore(db).Create(ctx, CreateVoteSubmissionOptions{
VoteID: vote.ID, OptionID: option.ID,
})
require.Nil(t, err)
require.Nil(t, NewBoxViewsStore(db).Record(ctx, RecordBoxViewOptions{
UserID: owner.ID, ViewedOn: time.Now(), Visitor: "visitor",
}))
require.Nil(t, usersStore.Deactivate(ctx, owner.ID))
assertCount := func(model interface{}, query string, args []interface{}, want int64) {
t.Helper()
var count int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(model).Where(query, args...).Count(&count).Error)
require.Equal(t, want, count)
}
assertCount(&MessageImage{}, "message_id = ?", []interface{}{message.ID}, 0)
assertCount(&Message{}, "conversation_id = ?", []interface{}{conversation.ID}, 0)
assertCount(&Conversation{}, "id = ?", []interface{}{conversation.ID}, 0)
assertCount(&VoteSubmission{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteFormField{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteOption{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&Vote{}, "id = ?", []interface{}{vote.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{ownedImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{orphanImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{sharedImage.ID}, 1)
assertCount(&UploadImage{}, "id = ?", []interface{}{otherImage.ID}, 1)
assertCount(&UploadImageQuestion{}, "question_id = ?", []interface{}{question.ID}, 0)
assertCount(&UploadImageQuestion{}, "upload_image_id = ? AND question_id = ?", []interface{}{sharedImage.ID, otherQuestion.ID}, 1)
assertCount(&BoxView{}, "user_id = ?", []interface{}{owner.ID}, 0)
assertCount(&Question{}, "id = ?", []interface{}{otherQuestion.ID}, 1)
}
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()