安全: 修复凭据、遥测与账号数据清理风险
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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") }()
|
||||
|
||||
Reference in New Issue
Block a user