feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -1016,6 +1016,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
|
|||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAccountSettingsBatch is the bounded cold loader behind the RPC read
|
||||
// model. Missing rows are returned as explicit defaults so they are negative
|
||||
// cached instead of being queried again.
|
||||
func (s *Service) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
|
||||
out := make(map[int64]domain.AccountSettings, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID > 0 {
|
||||
out[userID] = domain.DefaultAccountSettings()
|
||||
}
|
||||
}
|
||||
if s == nil || s.settings == nil || len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if batch, ok := s.settings.(store.AccountSettingsBatchStore); ok {
|
||||
loaded, err := batch.GetAccountSettingsBatch(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for userID, settings := range loaded {
|
||||
out[userID] = settings
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for userID := range out {
|
||||
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out[userID] = settings
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
|
||||
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
|
||||
settings, err := s.GetAccountSettings(ctx, userID)
|
||||
|
|
|
|||
53
internal/app/authdiagnostics/service.go
Normal file
53
internal/app/authdiagnostics/service.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package authdiagnostics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
codes store.CodeStore
|
||||
reports store.AuthDeliveryReportStore
|
||||
}
|
||||
|
||||
func NewService(codes store.CodeStore, reports store.AuthDeliveryReportStore) *Service {
|
||||
return &Service{codes: codes, reports: reports}
|
||||
}
|
||||
|
||||
func (s *Service) ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error) {
|
||||
phone := domain.NormalizePhone(req.Phone)
|
||||
if s == nil || s.codes == nil || s.reports == nil ||
|
||||
!domain.ValidPhone(phone) || req.PhoneCodeHash == "" {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
record, found, err := s.codes.Get(ctx, req.PhoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeExpired
|
||||
}
|
||||
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" ||
|
||||
record.Phone != phone || !store.LoginCodeChannelVerifiable(record.Channel) {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var channel domain.AuthCodeDeliveryKind
|
||||
switch record.Channel {
|
||||
case store.PhoneCodeChannelPhone:
|
||||
channel = domain.AuthCodeDeliveryPhone
|
||||
case store.PhoneCodeChannelSMS:
|
||||
channel = domain.AuthCodeDeliverySMS
|
||||
default:
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
report, err := domain.NewAuthDeliveryReport(
|
||||
req.AuthKeyID, req.SessionID, req.ClientType, phone, req.PhoneCodeHash,
|
||||
record.IssuedUserID, record.DeliveryID, channel, req.MNC, req.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
return s.reports.CreateAuthDeliveryReport(ctx, report)
|
||||
}
|
||||
81
internal/app/authdiagnostics/service_test.go
Normal file
81
internal/app/authdiagnostics/service_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package authdiagnostics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestReportMissingCodeValidatesLiveDeliveryAndStoresOnlyHashes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
reports := memory.NewAuthDeliveryReportStore()
|
||||
const (
|
||||
phone = "15550001234"
|
||||
codeHash = "login-code-hash"
|
||||
)
|
||||
if err := codes.Set(ctx, codeHash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent, Phone: phone, Code: "12345",
|
||||
DeliveryID: "delivery-1", Channel: store.PhoneCodeChannelSMS,
|
||||
IssuedUserID: 42,
|
||||
}, time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(codes, reports)
|
||||
now := time.Now().UTC()
|
||||
req := domain.AuthMissingCodeReportRequest{
|
||||
AuthKeyID: [8]byte{1, 2, 3}, SessionID: 99, ClientType: "tdesktop",
|
||||
Phone: "+1 (555) 000-1234", PhoneCodeHash: codeHash, MNC: "46000",
|
||||
CreatedAt: now,
|
||||
}
|
||||
first, created, err := service.ReportMissingCode(ctx, req)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first report created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := service.ReportMissingCode(ctx, req)
|
||||
if err != nil || created || second.ID != first.ID {
|
||||
t.Fatalf("retry report=%+v created=%v err=%v", second, created, err)
|
||||
}
|
||||
stored := reports.Reports()
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored reports=%d, want 1", len(stored))
|
||||
}
|
||||
if stored[0].PhoneHash != sha256.Sum256([]byte(phone)) ||
|
||||
stored[0].CodeHash != sha256.Sum256([]byte(codeHash)) {
|
||||
t.Fatalf("stored hashes do not match normalized delivery identity: %+v", stored[0])
|
||||
}
|
||||
if stored[0].DeliveryID != "delivery-1" || stored[0].IssuedUserID != 42 ||
|
||||
stored[0].Channel != domain.AuthCodeDeliverySMS {
|
||||
t.Fatalf("stored delivery metadata=%+v", stored[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportMissingCodeRejectsUnknownOrMismatchedLoginState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
service := NewService(codes, memory.NewAuthDeliveryReportStore())
|
||||
now := time.Now().UTC()
|
||||
base := domain.AuthMissingCodeReportRequest{
|
||||
AuthKeyID: [8]byte{1}, SessionID: 10, Phone: "15550002222",
|
||||
PhoneCodeHash: "missing", CreatedAt: now,
|
||||
}
|
||||
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("missing hash err=%v, want phone-code expired", err)
|
||||
}
|
||||
if err := codes.Set(ctx, "current", store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent, Phone: "15550003333",
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
}, time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.PhoneCodeHash = "current"
|
||||
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
|
||||
t.Fatalf("mismatched phone err=%v, want phone-code invalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1031,6 +1031,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
|
|||
return s.channels.ListChannelMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
type messageReactionLookupStore interface {
|
||||
FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||
}
|
||||
|
||||
func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
|
||||
req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
|
||||
req.ReactorUserID == 0 {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.ViewerUserID == 0 {
|
||||
req.ViewerUserID = userID
|
||||
}
|
||||
if req.ViewerUserID != userID {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
lookup, ok := s.channels.(messageReactionLookupStore)
|
||||
if !ok {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
return lookup.FindChannelMessageReaction(ctx, req)
|
||||
}
|
||||
|
||||
type messageReactionUsageStore interface {
|
||||
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
|
||||
}
|
||||
|
|
@ -1445,6 +1468,30 @@ func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.D
|
|||
return s.channels.DeleteChannelMessages(ctx, req)
|
||||
}
|
||||
|
||||
type moderationChannelMessageStore interface {
|
||||
ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||
}
|
||||
|
||||
// ModerationDeleteMessages is the explicit server-authority deletion path used
|
||||
// only by the durable moderation action worker. It never accepts a client
|
||||
// identity and therefore cannot be reached by ordinary RPC permission checks.
|
||||
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
|
||||
if s == nil || s.channels == nil || channelID <= 0 ||
|
||||
len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
}
|
||||
store, ok := s.channels.(moderationChannelMessageStore)
|
||||
if !ok {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
|
||||
}
|
||||
|
||||
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
|
||||
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
|
|||
31
internal/app/clienttelemetry/service.go
Normal file
31
internal/app/clienttelemetry/service.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package clienttelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store store.ClientTelemetryStore
|
||||
}
|
||||
|
||||
func NewService(telemetryStore store.ClientTelemetryStore) *Service {
|
||||
return &Service{store: telemetryStore}
|
||||
}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
|
||||
}
|
||||
event, err := domain.NewClientTelemetryEvent(
|
||||
userID, kind, peer, subjectIDs, payload, createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ClientTelemetryEvent{}, false, err
|
||||
}
|
||||
return s.store.CreateClientTelemetry(ctx, event)
|
||||
}
|
||||
|
|
@ -149,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
|||
return s.projectContact(ctx, userID, contact)
|
||||
}
|
||||
|
||||
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
|
||||
// AcceptContact creates the reciprocal contact for an existing one-way contact.
|
||||
// Phone visibility remains governed exclusively by account privacy rules; this
|
||||
// RPC has no protocol flag authorizing a hidden phone-number exception.
|
||||
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
|
|
@ -188,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
|
|||
return domain.Contact{}, err
|
||||
}
|
||||
s.InvalidateViewers(userID, contactUserID)
|
||||
if s.privacy != nil {
|
||||
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
}
|
||||
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
|
|||
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type ClientTelemetryRetentionStore interface {
|
||||
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type AuthDeliveryReportRetentionStore interface {
|
||||
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type ModerationRetentionStore interface {
|
||||
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
|
||||
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
||||
const botAPIConfirmedGrace = 15 * time.Minute
|
||||
|
|
@ -92,24 +105,29 @@ const (
|
|||
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
|
||||
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
userUpdates UserUpdateEventRetentionStore
|
||||
channelUpdates ChannelUpdateEventRetentionStore
|
||||
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
||||
orphanAuthKeys OrphanAuthKeyRetentionStore
|
||||
activeAuthKeys ActiveRawAuthKeyProvider
|
||||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
orphanRetention time.Duration
|
||||
outboxPoisonRetention time.Duration
|
||||
outboxPoisonInterval time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
userUpdates UserUpdateEventRetentionStore
|
||||
channelUpdates ChannelUpdateEventRetentionStore
|
||||
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
||||
clientTelemetry ClientTelemetryRetentionStore
|
||||
authDeliveryReports AuthDeliveryReportRetentionStore
|
||||
moderation ModerationRetentionStore
|
||||
orphanAuthKeys OrphanAuthKeyRetentionStore
|
||||
activeAuthKeys ActiveRawAuthKeyProvider
|
||||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
orphanRetention time.Duration
|
||||
clientTelemetryRetention time.Duration
|
||||
authDeliveryReportRetention time.Duration
|
||||
outboxPoisonRetention time.Duration
|
||||
outboxPoisonInterval time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||
|
|
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
|
|||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithClientTelemetryRetention(store ClientTelemetryRetentionStore, retention time.Duration) *RetentionWorker {
|
||||
if retention <= 0 {
|
||||
retention = 30 * 24 * time.Hour
|
||||
}
|
||||
w.clientTelemetry = store
|
||||
w.clientTelemetryRetention = retention
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithAuthDeliveryReportRetention(store AuthDeliveryReportRetentionStore, retention time.Duration) *RetentionWorker {
|
||||
if retention <= 0 {
|
||||
retention = 30 * 24 * time.Hour
|
||||
}
|
||||
w.authDeliveryReports = store
|
||||
w.authDeliveryReportRetention = retention
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithModerationRetention(store ModerationRetentionStore) *RetentionWorker {
|
||||
w.moderation = store
|
||||
return w
|
||||
}
|
||||
|
||||
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key,
|
||||
// 不能提供 temp→perm business key;否则未登录或 PFS 连接会被误判为 orphan。
|
||||
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
|
||||
|
|
@ -274,6 +315,45 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
|
|||
w.logger.Info("回收过期 login-code delivery 回执完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.clientTelemetry != nil {
|
||||
deleted, err := w.clientTelemetry.DeleteExpiredClientTelemetry(
|
||||
ctx, time.Now().Add(-w.clientTelemetryRetention), w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期客户端 telemetry 失败", zap.Error(err))
|
||||
} else if deleted > 0 {
|
||||
w.logger.Info("回收过期客户端 telemetry 完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.authDeliveryReports != nil {
|
||||
deleted, err := w.authDeliveryReports.DeleteExpiredAuthDeliveryReports(
|
||||
ctx, time.Now().Add(-w.authDeliveryReportRetention), w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期验证码投递诊断失败", zap.Error(err))
|
||||
} else if deleted > 0 {
|
||||
w.logger.Info("回收过期验证码投递诊断完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.moderation != nil {
|
||||
now := time.Now()
|
||||
impressions, err := w.moderation.DeleteExpiredSponsoredMessageImpressions(
|
||||
ctx, now, w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期 sponsored impression 失败", zap.Error(err))
|
||||
} else if impressions > 0 {
|
||||
w.logger.Info("回收过期 sponsored impression 完成", zap.Int("deleted", impressions))
|
||||
}
|
||||
links, err := w.moderation.DeleteExpiredModerationAppealLinks(
|
||||
ctx, now, w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期审核申诉链接失败", zap.Error(err))
|
||||
} else if links > 0 {
|
||||
w.logger.Info("回收过期审核申诉链接完成", zap.Int("deleted", links))
|
||||
}
|
||||
}
|
||||
if w.tempKeys != nil {
|
||||
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
|
||||
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,86 @@ func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type fakeReportRetention struct {
|
||||
telemetryBefore time.Time
|
||||
authBefore time.Time
|
||||
sponsoredBefore time.Time
|
||||
appealBefore time.Time
|
||||
telemetryCalls int
|
||||
authCalls int
|
||||
sponsoredCalls int
|
||||
appealCalls int
|
||||
limit int
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.telemetryCalls++
|
||||
f.telemetryBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.authCalls++
|
||||
f.authBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.sponsoredCalls++
|
||||
f.sponsoredBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.appealCalls++
|
||||
f.appealBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) {
|
||||
const (
|
||||
telemetryTTL = 7 * 24 * time.Hour
|
||||
authTTL = 14 * 24 * time.Hour
|
||||
batch = 47
|
||||
)
|
||||
store := &fakeReportRetention{}
|
||||
w := NewRetentionWorker(
|
||||
&fakeOutboxRetention{}, nil, zap.NewNop(),
|
||||
168*time.Hour, time.Hour, batch,
|
||||
).WithClientTelemetryRetention(store, telemetryTTL).
|
||||
WithAuthDeliveryReportRetention(store, authTTL).
|
||||
WithModerationRetention(store)
|
||||
before := time.Now()
|
||||
w.runRetentionOnce(context.Background())
|
||||
after := time.Now()
|
||||
if store.telemetryCalls != 1 || store.authCalls != 1 ||
|
||||
store.sponsoredCalls != 1 || store.appealCalls != 1 ||
|
||||
store.limit != batch {
|
||||
t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d",
|
||||
store.telemetryCalls, store.authCalls,
|
||||
store.sponsoredCalls, store.appealCalls, store.limit)
|
||||
}
|
||||
if store.telemetryBefore.Before(before.Add(-telemetryTTL)) ||
|
||||
store.telemetryBefore.After(after.Add(-telemetryTTL)) {
|
||||
t.Fatalf("telemetry boundary=%v", store.telemetryBefore)
|
||||
}
|
||||
if store.authBefore.Before(before.Add(-authTTL)) ||
|
||||
store.authBefore.After(after.Add(-authTTL)) {
|
||||
t.Fatalf("auth boundary=%v", store.authBefore)
|
||||
}
|
||||
if store.sponsoredBefore.Before(before) ||
|
||||
store.sponsoredBefore.After(after) ||
|
||||
store.appealBefore.Before(before) ||
|
||||
store.appealBefore.After(after) {
|
||||
t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v",
|
||||
store.sponsoredBefore, store.appealBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
||||
f.calls++
|
||||
f.confirmedGrace = confirmedGrace
|
||||
|
|
|
|||
512
internal/app/moderation/actions.go
Normal file
512
internal/app/moderation/actions.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type moderationAdminActions interface {
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleter interface {
|
||||
ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleteNotifier interface {
|
||||
NotifyModerationChannelDeletion(ctx context.Context, result domain.DeleteChannelMessagesResult)
|
||||
}
|
||||
|
||||
type moderationAccountDeleter interface {
|
||||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
}
|
||||
|
||||
type moderationAppealLinkIssuer interface {
|
||||
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
|
||||
}
|
||||
|
||||
type ActionExecutor struct {
|
||||
admin moderationAdminActions
|
||||
channels moderationChannelDeleter
|
||||
channelNotifier moderationChannelDeleteNotifier
|
||||
accounts moderationAccountDeleter
|
||||
appealLinks moderationAppealLinkIssuer
|
||||
publicBaseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ActionExecutorOption func(*ActionExecutor)
|
||||
|
||||
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
executor.appealLinks = issuer
|
||||
executor.publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||
}
|
||||
}
|
||||
|
||||
func WithActionClock(now func() time.Time) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
if now != nil {
|
||||
executor.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewActionExecutor(adminActions moderationAdminActions, channels moderationChannelDeleter, channelNotifier moderationChannelDeleteNotifier, accounts moderationAccountDeleter, opts ...ActionExecutorOption) *ActionExecutor {
|
||||
executor := &ActionExecutor{
|
||||
admin: adminActions, channels: channels,
|
||||
channelNotifier: channelNotifier, accounts: accounts,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(executor)
|
||||
}
|
||||
}
|
||||
return executor
|
||||
}
|
||||
|
||||
type freezeAccountActionPayload struct {
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
AppealURL string `json:"appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type deletePrivateMessageActionPayload struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
IDs []int `json:"ids"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
type deleteChannelMessageActionPayload struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCaseDetail, action domain.ModerationAction) error {
|
||||
if e == nil || action.CaseID != detail.Case.ID {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
actor, _, ok := decisionAuditContext(detail.Decisions, action.DecisionID)
|
||||
if !ok {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
meta := admin.CommandMeta{
|
||||
CommandID: action.CommandID, Actor: actor,
|
||||
Reason: fmt.Sprintf("moderation case %d decision %d", detail.Case.ID, action.DecisionID),
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, true, false, meta)
|
||||
case domain.ModerationActionMarkFake:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, true, meta)
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, false, meta)
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
frozen := action.Kind == domain.ModerationActionFreezeAccount
|
||||
if !frozen && (!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if frozen {
|
||||
now := e.now().UTC()
|
||||
if payload.Until.IsZero() {
|
||||
payload.Until = now.Add(30 * 24 * time.Hour)
|
||||
}
|
||||
if !payload.Until.After(now) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if payload.AppealURL == "" {
|
||||
if e.appealLinks == nil || e.publicBaseURL == "" {
|
||||
return fmt.Errorf("moderation appeal link issuer is not configured")
|
||||
}
|
||||
linkExpiresAt := payload.Until
|
||||
maxLinkExpiry := now.Add(domain.MaxModerationAppealLinkLifetime)
|
||||
if linkExpiresAt.After(maxLinkExpiry) {
|
||||
linkExpiresAt = maxLinkExpiry
|
||||
}
|
||||
token, err := e.appealLinks.IssueAppealLink(
|
||||
ctx, detail.Case.ID, detail.Case.Target.ID,
|
||||
linkExpiresAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.AppealURL = e.publicBaseURL + "/appeal/" + token
|
||||
}
|
||||
}
|
||||
_, err := e.admin.SetAccountFrozen(ctx, admin.SetAccountFrozenRequest{
|
||||
CommandMeta: meta, UserID: detail.Case.Target.ID, Frozen: frozen,
|
||||
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.OwnerUserID <= 0 || len(payload.IDs) == 0 ||
|
||||
len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
_, err := e.admin.DeletePrivateMessages(ctx, admin.DeletePrivateMessagesRequest{
|
||||
CommandMeta: meta, OwnerUserID: payload.OwnerUserID,
|
||||
Peer: detail.Case.Target, IDs: payload.IDs, Revoke: payload.Revoke,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
if e.channels == nil || detail.Case.Target.Type != domain.PeerTypeChannel {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload.IDs) == 0 || len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
result, err := e.channels.ModerationDeleteMessages(
|
||||
ctx, detail.Case.Target.ID, payload.IDs, int(e.now().Unix()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.channelNotifier != nil {
|
||||
e.channelNotifier.NotifyModerationChannelDeletion(ctx, result)
|
||||
}
|
||||
return nil
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.accounts.ExecuteAccountDeletion(
|
||||
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
|
||||
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
|
||||
)
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateDecisionActions(ctx context.Context, detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||
flagActions := 0
|
||||
freezeActions := 0
|
||||
hasDeleteAccount := false
|
||||
for _, action := range actions {
|
||||
if _, duplicate := seen[action.Kind]; duplicate {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
seen[action.Kind] = struct{}{}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
|
||||
domain.ModerationActionClearPeerFlags:
|
||||
flagActions++
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
freezeActions++
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Kind == domain.ModerationActionUnfreezeAccount &&
|
||||
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser ||
|
||||
payload.OwnerUserID <= 0 ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.privateDeletionCoveredByEvidence(ctx, detail, payload) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeChannel ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.channelDeletionCoveredByEvidence(ctx, detail, payload.IDs) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
hasDeleteAccount = true
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
if flagActions > 1 || freezeActions > 1 ||
|
||||
(hasDeleteAccount && len(actions) != 1) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) privateDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, payload deletePrivateMessageActionPayload) bool {
|
||||
needed := make(map[int]struct{}, len(payload.IDs))
|
||||
for _, id := range payload.IDs {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found || report.ReporterUserID != payload.OwnerUserID {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func (s *Service) channelDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, ids []int) bool {
|
||||
needed := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func validModerationMessageIDs(ids []int) bool {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) setPeerFlags(ctx context.Context, target domain.Peer, scam, fake bool, meta admin.CommandMeta) error {
|
||||
if e.admin == nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
switch target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, err := e.admin.SetUserFlags(ctx, admin.SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, err := e.admin.SetChannelFlags(ctx, admin.SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func decisionAuditContext(decisions []domain.ModerationDecision, decisionID int64) (string, string, bool) {
|
||||
for _, decision := range decisions {
|
||||
if decision.ID == decisionID {
|
||||
return decision.Actor, decision.Reason, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func decodeStrictActionPayload(raw json.RawMessage, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActionWorker struct {
|
||||
store store.ModerationCaseStore
|
||||
executor *ActionExecutor
|
||||
interval time.Duration
|
||||
lease time.Duration
|
||||
batch int
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewActionWorker(caseStore store.ModerationCaseStore, executor *ActionExecutor, log *zap.Logger) *ActionWorker {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &ActionWorker{
|
||||
store: caseStore, executor: executor,
|
||||
interval: time.Second, lease: 30 * time.Second, batch: 20, log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) Run(ctx context.Context) {
|
||||
if w == nil || w.store == nil || w.executor == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := w.runOnce(ctx); err != nil && ctx.Err() == nil {
|
||||
w.log.Warn("审核处置任务执行失败", zap.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) runOnce(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
actions, err := w.store.ClaimModerationActions(ctx, now, w.batch, w.lease)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, action := range actions {
|
||||
current, currentErr := w.store.IsModerationActionCurrent(ctx, action)
|
||||
if currentErr == nil && !current {
|
||||
if err := w.store.SupersedeModerationAction(
|
||||
ctx, action.ID, action.Attempts, time.Now().UTC(),
|
||||
); err != nil {
|
||||
w.log.Warn("提交已被新案件取代的审核处置失败",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
w.log.Info("审核处置已被同目标的更新处置取代",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, found, getErr := w.store.GetModerationCase(ctx, action.CaseID)
|
||||
execErr := currentErr
|
||||
if execErr == nil {
|
||||
execErr = getErr
|
||||
}
|
||||
if execErr == nil && !found {
|
||||
execErr = domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if execErr == nil {
|
||||
execErr = w.executor.Execute(ctx, detail, action)
|
||||
}
|
||||
finishedAt := time.Now().UTC()
|
||||
retryAt := finishedAt
|
||||
errorText := ""
|
||||
if execErr != nil {
|
||||
errorText = execErr.Error()
|
||||
retryAt = finishedAt.Add(moderationActionRetryDelay(action.Attempts))
|
||||
}
|
||||
if err := w.store.CompleteModerationAction(
|
||||
ctx, action.ID, action.Attempts, execErr == nil,
|
||||
errorText, retryAt, finishedAt,
|
||||
); err != nil {
|
||||
w.log.Warn("提交审核处置结果失败",
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if execErr != nil {
|
||||
w.log.Warn("审核处置等待重试",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(execErr))
|
||||
} else {
|
||||
w.log.Info("审核处置完成",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func moderationActionRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delay := time.Second << min(attempt-1, 10)
|
||||
if delay > time.Hour {
|
||||
return time.Hour
|
||||
}
|
||||
return delay
|
||||
}
|
||||
325
internal/app/moderation/actions_test.go
Normal file
325
internal/app/moderation/actions_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type captureModerationAdmin struct {
|
||||
userFlags []admin.SetUserFlagsRequest
|
||||
frozen []admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
a.frozen = append(a.frozen, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
a.userFlags = append(a.userFlags, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Add(-10 * time.Second)
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "impersonation confirmed",
|
||||
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 {
|
||||
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
|
||||
}
|
||||
flag := adminActions.userFlags[0]
|
||||
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
|
||||
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
|
||||
t.Fatalf("flag request=%+v", flag)
|
||||
}
|
||||
resolved, found, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
|
||||
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: command,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claimed.ID
|
||||
}
|
||||
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
|
||||
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
|
||||
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
|
||||
!adminActions.userFlags[0].Fake {
|
||||
t.Fatalf("flag actions=%+v", adminActions.userFlags)
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCaseID)
|
||||
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(oldDetail.Actions) != 1 ||
|
||||
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
|
||||
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
|
||||
}
|
||||
newDetail, _, err := service.Case(ctx, newCaseID)
|
||||
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(newDetail.Actions) != 1 ||
|
||||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("new detail=%+v err=%v", newDetail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: option,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", items, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return detail.Case
|
||||
}
|
||||
oldCase := createCase(101, "old", "old", now)
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
|
||||
if newCase.ID == oldCase.ID {
|
||||
t.Fatal("new report reused decided case")
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCase.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: oldCase.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: claimed.Version, Actor: "reviewer",
|
||||
Reason: "grant", CommandID: "stale-appeal",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
})
|
||||
if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("ReviewAppeal error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type captureAppealLinkIssuer struct {
|
||||
caseID int64
|
||||
appellantID int64
|
||||
expiresAt time.Time
|
||||
issuedAt time.Time
|
||||
returnedToken string
|
||||
}
|
||||
|
||||
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
i.caseID = caseID
|
||||
i.appellantID = appellantUserID
|
||||
i.expiresAt = expiresAt
|
||||
i.issuedAt = now
|
||||
return i.returnedToken, nil
|
||||
}
|
||||
|
||||
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
adminActions := &captureModerationAdmin{}
|
||||
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
|
||||
executor := NewActionExecutor(
|
||||
adminActions, nil, nil, nil,
|
||||
WithActionClock(func() time.Time { return now }),
|
||||
WithAppealLinks(issuer, "https://example.test/"),
|
||||
)
|
||||
detail := domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{
|
||||
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
|
||||
},
|
||||
Decisions: []domain.ModerationDecision{{
|
||||
ID: 30, Actor: "reviewer",
|
||||
}},
|
||||
}
|
||||
action := domain.ModerationAction{
|
||||
CaseID: 10, DecisionID: 30,
|
||||
Kind: domain.ModerationActionFreezeAccount,
|
||||
Payload: []byte(`{}`), CommandID: "freeze:000",
|
||||
}
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.frozen) != 1 {
|
||||
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
|
||||
}
|
||||
req := adminActions.frozen[0]
|
||||
wantUntil := now.Add(30 * 24 * time.Hour)
|
||||
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
|
||||
req.AppealURL != "https://example.test/appeal/token" {
|
||||
t.Fatalf("freeze request=%+v", req)
|
||||
}
|
||||
if issuer.caseID != 10 || issuer.appellantID != 20 ||
|
||||
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
|
||||
t.Fatalf("appeal issue=%+v", issuer)
|
||||
}
|
||||
|
||||
adminActions.frozen = nil
|
||||
longUntil := now.Add(365 * 24 * time.Hour)
|
||||
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adminActions.frozen[0].Until.Equal(longUntil) {
|
||||
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
|
||||
}
|
||||
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
|
||||
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
||||
}
|
||||
}
|
||||
83
internal/app/moderation/appeal_links.go
Normal file
83
internal/app/moderation/appeal_links.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const moderationAppealTokenBytes = 32
|
||||
|
||||
// IssueAppealLink creates a hash-only, time-bounded bearer capability. The raw
|
||||
// token is returned once and must only be embedded in the affected user's
|
||||
// client-visible appeal URL.
|
||||
func (s *Service) IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return "", fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
raw := make([]byte, moderationAppealTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate moderation appeal token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
link := domain.ModerationAppealLink{
|
||||
CaseID: caseID, AppellantUserID: appellantUserID,
|
||||
TokenHash: sha256.Sum256(raw), ExpiresAt: expiresAt.UTC(),
|
||||
CreatedAt: now.UTC(),
|
||||
}
|
||||
if _, err := s.cases.IssueModerationAppealLink(ctx, link); err == nil {
|
||||
return token, nil
|
||||
} else if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", domain.ErrModerationActionConflict
|
||||
}
|
||||
|
||||
func (s *Service) ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppealLink{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppealLink{}, false, err
|
||||
}
|
||||
return s.cases.GetModerationAppealLink(ctx, hash, now.UTC())
|
||||
}
|
||||
|
||||
func (s *Service) Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationAppeal(ctx, appealID)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.SubmitModerationAppealByLink(ctx, hash, text, now.UTC())
|
||||
}
|
||||
|
||||
func moderationAppealTokenHash(token string) ([sha256.Size]byte, error) {
|
||||
if len(token) != base64.RawURLEncoding.EncodedLen(moderationAppealTokenBytes) {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != moderationAppealTokenBytes ||
|
||||
base64.RawURLEncoding.EncodeToString(raw) != token {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
178
internal/app/moderation/appeal_links_test.go
Normal file
178
internal/app/moderation/appeal_links_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(
|
||||
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision",
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 43 {
|
||||
t.Fatalf("token length=%d", len(token))
|
||||
}
|
||||
link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second))
|
||||
if err != nil || !found || link.TokenHash == ([32]byte{}) {
|
||||
t.Fatalf("link=%+v found=%v err=%v", link, found, err)
|
||||
}
|
||||
expiredToken, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ {
|
||||
issuedAt := now.Add(time.Duration(20+i) * time.Second)
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("issue bounded link %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute),
|
||||
); !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("appeal link overflow err=%v", err)
|
||||
}
|
||||
if actions, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(5*time.Second), 10, time.Minute,
|
||||
); err != nil || len(actions) != 1 {
|
||||
t.Fatalf("actions=%+v err=%v", actions, err)
|
||||
} else if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(6*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "The account was impersonated.", now.Add(7*time.Second),
|
||||
)
|
||||
if err != nil || !created || appeal.CaseID != claimed.ID ||
|
||||
appeal.AppellantUserID != target.ID {
|
||||
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
|
||||
}
|
||||
retry, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "A different retry body must not create another appeal.",
|
||||
now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
appealed, found, err := service.Case(ctx, detail.Case.ID)
|
||||
if err != nil || !found ||
|
||||
appealed.Case.Status != domain.ModerationCaseAppealReview ||
|
||||
len(appealed.Appeals) != 1 {
|
||||
t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err)
|
||||
}
|
||||
appealClaim, err := service.ClaimCase(
|
||||
ctx, appealed.Case.ID, appealed.Case.Version,
|
||||
"appeal-reviewer", now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := domain.ModerationDecisionRequest{
|
||||
CaseID: appealClaim.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer",
|
||||
Reason: "original evidence was insufficient",
|
||||
CommandID: "appeal-grant-without-remedy",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
}
|
||||
if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is(
|
||||
err, domain.ErrModerationActionInvalid,
|
||||
) {
|
||||
t.Fatalf("grant without required flag remedy err=%v", err)
|
||||
}
|
||||
grant.CommandID = "appeal-grant-with-remedy"
|
||||
grant.Actions = []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}}
|
||||
granted, created, err := service.ReviewAppeal(ctx, grant)
|
||||
if err != nil || !created ||
|
||||
granted.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("granted=%+v created=%v err=%v", granted, created, err)
|
||||
}
|
||||
remedies, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(10*time.Second), 10, time.Minute,
|
||||
)
|
||||
if err != nil || len(remedies) != 1 ||
|
||||
remedies[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||
t.Fatalf("remedies=%+v err=%v", remedies, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, remedies[0].ID, remedies[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(11*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dismissed, found, err := service.Case(ctx, appealed.Case.ID)
|
||||
if err != nil || !found ||
|
||||
dismissed.Case.Status != domain.ModerationCaseDismissed {
|
||||
t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err)
|
||||
}
|
||||
|
||||
if _, found, err := service.ResolveAppealLink(
|
||||
ctx, expiredToken, now.Add(10*time.Second),
|
||||
); err != nil || found {
|
||||
t.Fatalf("expired resolve found=%v err=%v", found, err)
|
||||
}
|
||||
if _, _, err := service.SubmitAppealLink(
|
||||
ctx, expiredToken, "too late", now.Add(10*time.Second),
|
||||
); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) {
|
||||
t.Fatalf("expired submit err=%v", err)
|
||||
}
|
||||
if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is(
|
||||
err, domain.ErrModerationAppealLinkInvalid,
|
||||
) {
|
||||
t.Fatalf("invalid token err=%v", err)
|
||||
}
|
||||
}
|
||||
180
internal/app/moderation/cases.go
Normal file
180
internal/app/moderation/cases.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return nil, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ListModerationCases(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationCase(ctx, caseID)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCase{}, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ClaimModerationCase(ctx, caseID, expectedVersion, actor, now)
|
||||
}
|
||||
|
||||
func (s *Service) DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
return s.cases.DecideModerationCase(ctx, prepared)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, caseID)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
switch detail.Case.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if detail.Case.Target.ID != appellantUserID {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, appellantUserID, detail.Case.Target.ID)
|
||||
if err != nil || view.Forbidden ||
|
||||
(view.Self.Role != domain.ChannelRoleCreator &&
|
||||
view.Self.Role != domain.ChannelRoleAdmin) {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
default:
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
appeal, err := domain.NewModerationAppeal(
|
||||
caseID, appellantUserID, detail.Case.Status, text, now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.CreateModerationAppeal(ctx, appeal)
|
||||
}
|
||||
|
||||
func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.AppealID <= 0 ||
|
||||
(prepared.Kind != domain.ModerationDecisionAppealGrant &&
|
||||
prepared.Kind != domain.ModerationDecisionAppealDeny) {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
appealFound := false
|
||||
for _, appeal := range detail.Appeals {
|
||||
if appeal.ID == prepared.AppealID &&
|
||||
appeal.Status == domain.ModerationAppealPending {
|
||||
appealFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !appealFound {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.Kind == domain.ModerationDecisionAppealGrant {
|
||||
if err := validateAppealRemedyActions(detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
}
|
||||
return s.cases.ReviewModerationAppeal(ctx, prepared)
|
||||
}
|
||||
|
||||
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||
var flagsActive, freezeActive, irreversible bool
|
||||
for _, action := range history {
|
||||
if action.Status != domain.ModerationActionSucceeded {
|
||||
continue
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake:
|
||||
flagsActive = true
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
flagsActive = false
|
||||
case domain.ModerationActionFreezeAccount:
|
||||
freezeActive = true
|
||||
case domain.ModerationActionUnfreezeAccount:
|
||||
freezeActive = false
|
||||
case domain.ModerationActionDeletePrivateMessage,
|
||||
domain.ModerationActionDeleteChannelMessage,
|
||||
domain.ModerationActionDeleteAccount:
|
||||
irreversible = true
|
||||
}
|
||||
}
|
||||
if irreversible {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
expected := make(map[domain.ModerationActionKind]bool, 2)
|
||||
if flagsActive {
|
||||
expected[domain.ModerationActionClearPeerFlags] = true
|
||||
}
|
||||
if freezeActive {
|
||||
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||
}
|
||||
if len(actions) != len(expected) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !expected[action.Kind] {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
delete(expected, action.Kind)
|
||||
}
|
||||
if len(expected) != 0 {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
43
internal/app/moderation/cases_test.go
Normal file
43
internal/app/moderation/cases_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestValidateAppealRemedyActionsMatchesOnlyAppliedReversibleState(t *testing.T) {
|
||||
detail := domain.ModerationCaseDetail{Actions: []domain.ModerationAction{
|
||||
{ID: 2, Kind: domain.ModerationActionFreezeAccount, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 1, Kind: domain.ModerationActionMarkScam, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 3, Kind: domain.ModerationActionDeletePrivateMessage, Status: domain.ModerationActionFailed},
|
||||
}}
|
||||
remedies := []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionClearPeerFlags},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, remedies); err != nil {
|
||||
t.Fatalf("valid remedies err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies[:1],
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("missing unfreeze err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionMarkFake},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("new punishment in appeal err=%v", err)
|
||||
}
|
||||
detail.Actions = append(detail.Actions, domain.ModerationAction{
|
||||
ID: 4, Kind: domain.ModerationActionDeletePrivateMessage,
|
||||
Status: domain.ModerationActionSucceeded,
|
||||
})
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies,
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("irreversible grant err=%v", err)
|
||||
}
|
||||
}
|
||||
739
internal/app/moderation/evidence.go
Normal file
739
internal/app/moderation/evidence.go
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type privateMessageReader interface {
|
||||
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
|
||||
}
|
||||
|
||||
type channelMessageReader interface {
|
||||
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
|
||||
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||
}
|
||||
|
||||
type storyReader interface {
|
||||
GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error)
|
||||
}
|
||||
|
||||
type userReader interface {
|
||||
ByID(ctx context.Context, viewerUserID, userID int64) (domain.User, bool, error)
|
||||
}
|
||||
|
||||
type channelPeerReader interface {
|
||||
ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
|
||||
}
|
||||
|
||||
type profilePhotoReader interface {
|
||||
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error)
|
||||
}
|
||||
|
||||
func (s *Service) ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error) {
|
||||
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
switch req.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.privateMessages == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation private message reader is not configured")
|
||||
}
|
||||
list, err := s.privateMessages.GetMessages(ctx, req.ReporterUserID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, message := range list.Messages {
|
||||
if message.Peer == req.Target {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(privateMessageEvidence(message))
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal private message evidence: %w", err)
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||
ItemID: int64(message.ID), AuthorUserID: message.From.ID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel message reader is not configured")
|
||||
}
|
||||
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.Target.ID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, message := range history.Messages {
|
||||
if message.ChannelID == req.Target.ID && !message.Deleted {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := marshalChannelMessageEvidence(message)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||
ItemID: int64(message.ID), AuthorUserID: message.SenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID,
|
||||
Source: domain.ModerationSourceMessages,
|
||||
Target: req.Target,
|
||||
Reason: req.Reason,
|
||||
Option: req.Option,
|
||||
Comment: req.Comment,
|
||||
Items: items,
|
||||
MediaHolds: dedupeMediaHolds(holds),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
if source != domain.ModerationSourceAccountPeer && source != domain.ModerationSourceMessagesSpam {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
snapshot := peerEvidenceV1{SchemaVersion: 1, Target: target}
|
||||
switch target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.users == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation user reader is not configured")
|
||||
}
|
||||
user, found, err := s.users.ByID(ctx, reporterUserID, target.ID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found || user.Deleted {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
snapshot.User = &peerUserEvidenceV1{
|
||||
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
|
||||
Username: user.Username, About: user.About, Bot: user.Bot,
|
||||
Verified: user.Verified, Scam: user.Scam, Fake: user.Fake,
|
||||
PhotoID: user.PhotoID,
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel reader is not configured")
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, reporterUserID, target.ID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
channel := view.Channel
|
||||
snapshot.Channel = &peerChannelEvidenceV1{
|
||||
ID: channel.ID, Title: channel.Title, About: channel.About,
|
||||
Username: channel.Username, Broadcast: channel.Broadcast,
|
||||
Megagroup: channel.Megagroup, Verified: channel.Verified,
|
||||
Scam: channel.Scam, Fake: channel.Fake, PhotoID: channel.PhotoID,
|
||||
}
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
evidence, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal peer evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if target.Type == domain.PeerTypeUser {
|
||||
authorUserID = target.ID
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: source, Target: target,
|
||||
Reason: reason, Option: option, Comment: comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.PhotoID <= 0 ||
|
||||
!req.Reason.Valid() || s == nil || s.photos == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
photos, _, err := s.photos.GetProfilePhotos(ctx, req.Target.Type, req.Target.ID, -1, 1, req.PhotoID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if len(photos) != 1 || photos[0].ID != req.PhotoID ||
|
||||
photos[0].AccessHash != req.AccessHash {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
photo := photos[0]
|
||||
if len(req.FileReference) > 0 && len(photo.FileReference) > 0 &&
|
||||
!bytes.Equal(req.FileReference, photo.FileReference) {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(profilePhotoEvidenceV1{
|
||||
SchemaVersion: 1, Owner: req.Target, Photo: photo,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal profile photo evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if req.Target.Type == domain.PeerTypeUser {
|
||||
authorUserID = req.Target.ID
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceProfilePhoto,
|
||||
Target: req.Target, Reason: req.Reason, Option: string(req.Reason),
|
||||
Comment: req.Comment, CreatedAt: req.CreatedAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemProfilePhoto, Peer: req.Target,
|
||||
ItemID: req.PhotoID, AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: photoHolds(0, photo),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.ChannelID <= 0 || req.ParticipantUserID <= 0 ||
|
||||
s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.ChannelID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, message := range history.Messages {
|
||||
if message.ChannelID == req.ChannelID && !message.Deleted {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found || message.SenderUserID != req.ParticipantUserID {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := marshalChannelMessageEvidence(message)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: target,
|
||||
ItemID: int64(message.ID), AuthorUserID: req.ParticipantUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceChannelSpam,
|
||||
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
Items: items, MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
var evidence []byte
|
||||
var err error
|
||||
switch req.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.privateMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
result, err := s.privateMessages.GetMessageReactions(ctx, req.ReporterUserID, domain.PrivateMessageReactionsRequest{
|
||||
OwnerUserID: req.ReporterUserID, Peer: req.Target, IDs: []int{req.MessageID},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if len(result.Messages) != 1 || result.Messages[0].ID != req.MessageID ||
|
||||
result.Messages[0].Peer != req.Target {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
reactions := reactionRowsForUser(result.Messages[0].Reactions, req.ReactorUserID)
|
||||
if len(reactions) == 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err = json.Marshal(privateReactionEvidenceV1{
|
||||
SchemaVersion: 1, Message: privateMessageEvidence(result.Messages[0]),
|
||||
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(reactions),
|
||||
})
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
lookup, found, lookupErr := s.channelMessages.FindMessageReaction(ctx, req.ReporterUserID, domain.ChannelMessageReactionLookupRequest{
|
||||
ViewerUserID: req.ReporterUserID, ChannelID: req.Target.ID,
|
||||
MessageID: req.MessageID, ReactorUserID: req.ReactorUserID,
|
||||
})
|
||||
if lookupErr != nil {
|
||||
return domain.ModerationReport{}, false, lookupErr
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err = json.Marshal(channelReactionEvidenceV1{
|
||||
SchemaVersion: 1, Message: channelMessageEvidence(lookup.Message),
|
||||
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(lookup.Reactions),
|
||||
})
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal reaction evidence: %w", err)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceReaction,
|
||||
Target: req.Target, Reason: domain.ModerationReasonOther,
|
||||
Option: "reaction", CreatedAt: req.CreatedAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemReaction, Peer: req.Target,
|
||||
ItemID: int64(req.MessageID), SecondaryID: req.ReactorUserID,
|
||||
AuthorUserID: req.ReactorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
if reporterUserID <= 0 || !chat.HasParticipant(reporterUserID) || chat.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
offenderUserID := chat.PeerOf(reporterUserID)
|
||||
if offenderUserID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: offenderUserID}
|
||||
evidence, err := json.Marshal(encryptedChatEvidenceV1{
|
||||
SchemaVersion: 1, ChatID: chat.ID, State: chat.State,
|
||||
AdminUserID: chat.AdminUserID, ParticipantUserID: chat.ParticipantUserID,
|
||||
Date: chat.Date,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal encrypted chat evidence: %w", err)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEncryptedSpam,
|
||||
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
CreatedAt: createdAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEncryptedChat, Peer: target,
|
||||
ItemID: int64(chat.ID), AuthorUserID: offenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error) {
|
||||
ids, err := canonicalPositiveIDs(req.StoryIDs, domain.MaxStoryID)
|
||||
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation story reader is not configured")
|
||||
}
|
||||
list, err := s.stories.GetStoriesByID(ctx, req.ReporterUserID, req.Target, ids, int(req.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.Story, len(list.Stories))
|
||||
for _, story := range list.Stories {
|
||||
if story.Owner == req.Target && !story.Deleted {
|
||||
byID[story.ID] = story
|
||||
}
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
for _, id := range ids {
|
||||
story, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(storyEvidenceV1{
|
||||
SchemaVersion: 1, Owner: story.Owner, StoryID: story.ID,
|
||||
Date: story.Date, ExpireDate: story.ExpireDate, Pinned: story.Pinned,
|
||||
Public: story.Public, CloseFriends: story.CloseFriends,
|
||||
Contacts: story.Contacts, SelectedContacts: story.SelectedContacts,
|
||||
NoForwards: story.NoForwards, Edited: story.Edited,
|
||||
Caption: story.Caption, Entities: story.Entities, Media: story.Media,
|
||||
MediaAreas: story.MediaAreas, Forward: story.Forward,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal story evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if story.Owner.Type == domain.PeerTypeUser {
|
||||
authorUserID = story.Owner.ID
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemStory, Peer: story.Owner,
|
||||
ItemID: int64(story.ID), AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, story.Media)...)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceStory,
|
||||
Target: req.Target, Reason: req.Reason, Option: req.Option,
|
||||
Comment: req.Comment, Items: items,
|
||||
MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
legacy := domain.NewEphemeralAbuseReport(reporterUserID, option, comment, target, createdAt)
|
||||
if err := legacy.Validate(); err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
evidence, err := json.Marshal(struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
|
||||
}
|
||||
holds := mediaHolds(0, target.Content.Media)
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEphemeral,
|
||||
Target: target.Peer, Reason: reason, Option: option, Comment: comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEphemeral, Peer: target.Peer,
|
||||
ItemID: int64(target.ID), AuthorUserID: target.SenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: holds, CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
type peerEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Target domain.Peer `json:"target"`
|
||||
User *peerUserEvidenceV1 `json:"user,omitempty"`
|
||||
Channel *peerChannelEvidenceV1 `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
type peerUserEvidenceV1 struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
About string `json:"about"`
|
||||
Bot bool `json:"bot,omitempty"`
|
||||
Verified bool `json:"verified,omitempty"`
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
PhotoID int64 `json:"photo_id,omitempty"`
|
||||
}
|
||||
|
||||
type peerChannelEvidenceV1 struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
About string `json:"about"`
|
||||
Username string `json:"username"`
|
||||
Broadcast bool `json:"broadcast,omitempty"`
|
||||
Megagroup bool `json:"megagroup,omitempty"`
|
||||
Verified bool `json:"verified,omitempty"`
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
PhotoID int64 `json:"photo_id,omitempty"`
|
||||
}
|
||||
|
||||
type profilePhotoEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Owner domain.Peer `json:"owner"`
|
||||
Photo domain.Photo `json:"photo"`
|
||||
}
|
||||
|
||||
type privateReactionEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Message privateMessageEvidenceV1 `json:"message"`
|
||||
ReactorUserID int64 `json:"reactor_user_id"`
|
||||
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||
}
|
||||
|
||||
type channelReactionEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Message channelMessageEvidenceV1 `json:"message"`
|
||||
ReactorUserID int64 `json:"reactor_user_id"`
|
||||
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||
}
|
||||
|
||||
type messageReactionEvidenceV1 struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Type domain.MessageReactionType `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Big bool `json:"big,omitempty"`
|
||||
Unread bool `json:"unread,omitempty"`
|
||||
ChosenOrder int `json:"chosen_order,omitempty"`
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
type encryptedChatEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChatID int `json:"chat_id"`
|
||||
State domain.SecretChatState `json:"state"`
|
||||
AdminUserID int64 `json:"admin_user_id"`
|
||||
ParticipantUserID int64 `json:"participant_user_id"`
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
type privateMessageEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
MessageID int `json:"message_id"`
|
||||
UID int64 `json:"uid"`
|
||||
Peer domain.Peer `json:"peer"`
|
||||
From domain.Peer `json:"from"`
|
||||
Date int `json:"date"`
|
||||
EditDate int `json:"edit_date,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||
}
|
||||
|
||||
type channelMessageEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
From domain.Peer `json:"from"`
|
||||
SendAs *domain.Peer `json:"send_as,omitempty"`
|
||||
Date int `json:"date"`
|
||||
EditDate int `json:"edit_date,omitempty"`
|
||||
Post bool `json:"post,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||
Action *domain.ChannelMessageAction `json:"action,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||
}
|
||||
|
||||
type storyEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Owner domain.Peer `json:"owner"`
|
||||
StoryID int `json:"story_id"`
|
||||
Date int `json:"date"`
|
||||
ExpireDate int `json:"expire_date"`
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
Public bool `json:"public,omitempty"`
|
||||
CloseFriends bool `json:"close_friends,omitempty"`
|
||||
Contacts bool `json:"contacts,omitempty"`
|
||||
SelectedContacts bool `json:"selected_contacts,omitempty"`
|
||||
NoForwards bool `json:"no_forwards,omitempty"`
|
||||
Edited bool `json:"edited,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
MediaAreas []domain.StoryMediaArea `json:"media_areas,omitempty"`
|
||||
Forward *domain.StoryForward `json:"forward,omitempty"`
|
||||
}
|
||||
|
||||
func privateMessageEvidence(message domain.Message) privateMessageEvidenceV1 {
|
||||
return privateMessageEvidenceV1{
|
||||
SchemaVersion: 1, MessageID: message.ID, UID: message.UID,
|
||||
Peer: message.Peer, From: message.From, Date: message.Date,
|
||||
EditDate: message.EditDate, Body: message.Body,
|
||||
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||
Forward: message.Forward, Reactions: message.Reactions,
|
||||
Media: message.Media, RichMessage: message.RichMessage,
|
||||
GroupedID: message.GroupedID,
|
||||
}
|
||||
}
|
||||
|
||||
func channelMessageEvidence(message domain.ChannelMessage) channelMessageEvidenceV1 {
|
||||
return channelMessageEvidenceV1{
|
||||
SchemaVersion: 1, ChannelID: message.ChannelID,
|
||||
MessageID: message.ID, SenderUserID: message.SenderUserID,
|
||||
From: message.From, SendAs: message.SendAs, Date: message.Date,
|
||||
EditDate: message.EditDate, Post: message.Post, Body: message.Body,
|
||||
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||
Forward: message.Forward, Reactions: message.Reactions,
|
||||
Action: message.Action, Media: message.Media,
|
||||
RichMessage: message.RichMessage, GroupedID: message.GroupedID,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalChannelMessageEvidence(message domain.ChannelMessage) ([]byte, error) {
|
||||
evidence, err := json.Marshal(channelMessageEvidence(message))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func reactionRowsForUser(reactions *domain.ChannelMessageReactions, userID int64) []domain.ChannelMessagePeerReaction {
|
||||
if reactions == nil || userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]domain.ChannelMessagePeerReaction, 0, len(reactions.Recent))
|
||||
for _, reaction := range reactions.Recent {
|
||||
if reaction.UserID == userID {
|
||||
rows = append(rows, reaction)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func reactionEvidenceRows(rows []domain.ChannelMessagePeerReaction) []messageReactionEvidenceV1 {
|
||||
out := make([]messageReactionEvidenceV1, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, messageReactionEvidenceV1{
|
||||
UserID: row.UserID, Type: row.Reaction.Type,
|
||||
Value: row.Reaction.Value(), Big: row.Big, Unread: row.Unread,
|
||||
ChosenOrder: row.ChosenOrder, Date: row.Date,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].ChosenOrder != out[j].ChosenOrder {
|
||||
return out[i].ChosenOrder < out[j].ChosenOrder
|
||||
}
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return out[i].Value < out[j].Value
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func canonicalPositiveIDs(ids []int, max int) ([]int, error) {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxModerationReportItems {
|
||||
return nil, domain.ErrModerationReportInvalid
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
out := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > max {
|
||||
return nil, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if _, duplicate := seen[id]; !duplicate {
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mediaHolds(itemIndex int, media *domain.MessageMedia) []domain.ModerationMediaHold {
|
||||
if media == nil {
|
||||
return nil
|
||||
}
|
||||
holds := make([]domain.ModerationMediaHold, 0, 8)
|
||||
addPhoto := func(photo *domain.Photo) {
|
||||
if photo == nil || photo.ID <= 0 {
|
||||
return
|
||||
}
|
||||
for _, size := range photo.Sizes {
|
||||
if size.Type != "" {
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
addDocument := func(document *domain.Document) {
|
||||
if document == nil || document.ID <= 0 {
|
||||
return
|
||||
}
|
||||
prefix := "doc:" + strconv.FormatInt(document.ID, 10)
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||
StorageKey: prefix,
|
||||
})
|
||||
for _, thumb := range document.Thumbs {
|
||||
if thumb.Type != "" {
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||
StorageKey: prefix + ":" + thumb.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
addPhoto(media.Photo)
|
||||
addDocument(media.Document)
|
||||
addDocument(media.LivePhotoVideo)
|
||||
return dedupeMediaHolds(holds)
|
||||
}
|
||||
|
||||
func photoHolds(itemIndex int, photo domain.Photo) []domain.ModerationMediaHold {
|
||||
if photo.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
holds := make([]domain.ModerationMediaHold, 0, len(photo.Sizes))
|
||||
for _, size := range photo.Sizes {
|
||||
if size.Type == "" {
|
||||
continue
|
||||
}
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||
})
|
||||
}
|
||||
return dedupeMediaHolds(holds)
|
||||
}
|
||||
|
||||
func dedupeMediaHolds(holds []domain.ModerationMediaHold) []domain.ModerationMediaHold {
|
||||
if len(holds) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.ModerationMediaHold]struct{}, len(holds))
|
||||
out := make([]domain.ModerationMediaHold, 0, len(holds))
|
||||
for _, hold := range holds {
|
||||
if _, duplicate := seen[hold]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[hold] = struct{}{}
|
||||
out = append(out, hold)
|
||||
}
|
||||
return out
|
||||
}
|
||||
106
internal/app/moderation/legacy.go
Normal file
106
internal/app/moderation/legacy.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// MigrateLegacyEphemeralReports converts every pre-unified durable report into
|
||||
// the canonical moderation shape. The store commits the new report and its
|
||||
// legacy provenance mapping atomically; rerunning after a crash is safe.
|
||||
func (s *Service) MigrateLegacyEphemeralReports(ctx context.Context, source store.LegacyEphemeralReportReader, batchSize int) (int, error) {
|
||||
if s == nil || s.reports == nil || source == nil {
|
||||
return 0, fmt.Errorf("legacy ephemeral report migration is not configured")
|
||||
}
|
||||
if batchSize <= 0 || batchSize > 1000 {
|
||||
return 0, fmt.Errorf("legacy ephemeral report batch limit out of range")
|
||||
}
|
||||
importer, ok := s.reports.(store.LegacyEphemeralReportImporter)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("moderation report store does not support legacy imports")
|
||||
}
|
||||
migrated := 0
|
||||
for {
|
||||
rows, err := source.ListUnmigratedEphemeralReports(ctx, batchSize)
|
||||
if err != nil {
|
||||
return migrated, err
|
||||
}
|
||||
for _, legacy := range rows {
|
||||
report, err := legacyEphemeralModerationReport(legacy.Report)
|
||||
if err != nil {
|
||||
return migrated, fmt.Errorf("convert legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
if _, _, err := importer.ImportLegacyEphemeralReport(ctx, legacy.ID, report); err != nil {
|
||||
return migrated, fmt.Errorf("import legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
if len(rows) < batchSize {
|
||||
return migrated, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReport(legacy domain.EphemeralAbuseReport) (domain.ModerationReport, error) {
|
||||
if err := legacy.Validate(); err != nil {
|
||||
return domain.ModerationReport{}, err
|
||||
}
|
||||
reason, ok := legacyEphemeralModerationReason(legacy.Option)
|
||||
if !ok {
|
||||
return domain.ModerationReport{}, fmt.Errorf("%w: unsupported legacy option %q", domain.ErrModerationReportInvalid, legacy.Option)
|
||||
}
|
||||
evidence, err := json.Marshal(struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, fmt.Errorf("marshal legacy ephemeral evidence: %w", err)
|
||||
}
|
||||
return domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: legacy.ReporterUserID,
|
||||
Source: domain.ModerationSourceEphemeral,
|
||||
Target: legacy.Evidence.Peer,
|
||||
Reason: reason,
|
||||
Option: legacy.Option,
|
||||
Comment: legacy.Comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEphemeral,
|
||||
Peer: legacy.Evidence.Peer,
|
||||
ItemID: int64(legacy.Evidence.MessageID),
|
||||
AuthorUserID: legacy.Evidence.SenderUserID,
|
||||
EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: mediaHolds(0, legacy.Evidence.Content.Media),
|
||||
CreatedAt: legacy.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReason(option string) (domain.ModerationReason, bool) {
|
||||
switch option {
|
||||
case "spam":
|
||||
return domain.ModerationReasonSpam, true
|
||||
case "violence":
|
||||
return domain.ModerationReasonViolence, true
|
||||
case "pornography":
|
||||
return domain.ModerationReasonPornography, true
|
||||
case "child_abuse":
|
||||
return domain.ModerationReasonChildAbuse, true
|
||||
case "illegal_drugs":
|
||||
return domain.ModerationReasonIllegalDrugs, true
|
||||
case "personal_details":
|
||||
return domain.ModerationReasonPersonalDetails, true
|
||||
case "copyright":
|
||||
return domain.ModerationReasonCopyright, true
|
||||
case "fake":
|
||||
return domain.ModerationReasonFake, true
|
||||
case "other", "other:comment":
|
||||
return domain.ModerationReasonOther, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
88
internal/app/moderation/legacy_test.go
Normal file
88
internal/app/moderation/legacy_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type legacyEphemeralReader struct {
|
||||
rows []store.LegacyEphemeralReport
|
||||
}
|
||||
|
||||
func (r *legacyEphemeralReader) ListUnmigratedEphemeralReports(_ context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
|
||||
if len(r.rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > len(r.rows) {
|
||||
limit = len(r.rows)
|
||||
}
|
||||
out := append([]store.LegacyEphemeralReport(nil), r.rows[:limit]...)
|
||||
r.rows = r.rows[limit:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type legacyModerationImporter struct {
|
||||
*memory.ModerationReportStore
|
||||
mappings map[int64]int64
|
||||
}
|
||||
|
||||
func (s *legacyModerationImporter) ImportLegacyEphemeralReport(ctx context.Context, legacyID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
||||
if reportID, ok := s.mappings[legacyID]; ok {
|
||||
existing, _, err := s.GetModerationReport(ctx, reportID)
|
||||
return existing, false, err
|
||||
}
|
||||
stored, created, err := s.CreateModerationReport(ctx, report)
|
||||
if err == nil {
|
||||
s.mappings[legacyID] = stored.ID
|
||||
}
|
||||
return stored, created, err
|
||||
}
|
||||
|
||||
func TestMigrateLegacyEphemeralReportsPreservesEvidenceAndMediaHolds(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
reporter := int64(101)
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 44, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 303},
|
||||
SenderUserID: 202, ReceiverUserID: reporter, Date: int(now.Unix()),
|
||||
Content: domain.EphemeralContent{
|
||||
Message: "evidence",
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindDocument,
|
||||
Document: &domain.Document{
|
||||
ID: 909, AccessHash: 1, MimeType: "text/plain", Size: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
legacy := domain.NewEphemeralAbuseReport(reporter, "spam", "review", message, now)
|
||||
source := &legacyEphemeralReader{rows: []store.LegacyEphemeralReport{{ID: 7, Report: legacy}}}
|
||||
target := &legacyModerationImporter{
|
||||
ModerationReportStore: memory.NewModerationReportStore(),
|
||||
mappings: make(map[int64]int64),
|
||||
}
|
||||
service := NewService(target)
|
||||
count, err := service.MigrateLegacyEphemeralReports(context.Background(), source, 10)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("migrate count=%d err=%v", count, err)
|
||||
}
|
||||
reports := target.Reports()
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("reports=%d, want 1", len(reports))
|
||||
}
|
||||
got := reports[0]
|
||||
if got.Source != domain.ModerationSourceEphemeral ||
|
||||
got.Target != message.Peer || len(got.Items) != 1 ||
|
||||
got.Items[0].AuthorUserID != message.SenderUserID {
|
||||
t.Fatalf("migrated report=%+v", got)
|
||||
}
|
||||
if len(got.MediaHolds) != 1 ||
|
||||
got.MediaHolds[0].StorageKey != "doc:909" {
|
||||
t.Fatalf("media holds=%+v", got.MediaHolds)
|
||||
}
|
||||
}
|
||||
102
internal/app/moderation/registry_reports.go
Normal file
102
internal/app/moderation/registry_reports.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error) {
|
||||
if s == nil || s.registry == nil || len(randomID) == 0 {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
impression, found, err := s.registry.GetSponsoredMessageImpression(
|
||||
ctx, userID, sha256.Sum256(randomID), now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SponsoredMessageImpression{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationImpressionExpired
|
||||
}
|
||||
return impression, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
impression, err := s.SponsoredImpression(ctx, userID, randomID, now)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if impression.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, impression.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: userID, Source: domain.ModerationSourceSponsored,
|
||||
Target: impression.Target, Reason: reason, Option: option,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemSponsored, Peer: impression.Target,
|
||||
ItemID: impression.ID, AuthorUserID: impression.AuthorUserID,
|
||||
EvidenceSchemaVersion: impression.EvidenceSchemaVersion,
|
||||
Evidence: impression.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateSponsoredModerationReport(ctx, impression.ID, report)
|
||||
}
|
||||
|
||||
func (s *Service) ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
decision, found, err := s.registry.GetChannelAntiSpamDecision(
|
||||
ctx, channelID, messageID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
if decision.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, decision.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID,
|
||||
Source: domain.ModerationSourceAntiSpamFalsePositive,
|
||||
Target: target,
|
||||
Reason: domain.ModerationReasonOther,
|
||||
Option: "false_positive",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemAntiSpamDecision, Peer: target,
|
||||
ItemID: decision.ID, SecondaryID: int64(messageID),
|
||||
AuthorUserID: decision.AuthorUserID,
|
||||
EvidenceSchemaVersion: decision.EvidenceSchemaVersion,
|
||||
Evidence: decision.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateAntiSpamFalsePositiveReport(ctx, decision.ID, report)
|
||||
}
|
||||
98
internal/app/moderation/registry_reports_test.go
Normal file
98
internal/app/moderation/registry_reports_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestSponsoredReportRequiresIssuedImpressionAndLinksAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
randomID := []byte("server-issued-random-id")
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now,
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("unseen impression err=%v", err)
|
||||
}
|
||||
impression, err := domain.NewSponsoredMessageImpression(
|
||||
11, randomID, domain.Peer{Type: domain.PeerTypeChannel, ID: 22},
|
||||
33, []byte(`{"author_id":33,"creative_id":"creative-1"}`),
|
||||
now, now.Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
|
||||
}
|
||||
report, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonFake, "fake", now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != impression.EvidenceHash {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, []byte("expired"),
|
||||
domain.ModerationReasonSpam, "spam", now.Add(2*time.Hour),
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("expired/unseen err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntiSpamFalsePositiveRequiresNativeDecisionAndIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
if _, _, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now,
|
||||
); !errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||
t.Fatalf("missing decision err=%v", err)
|
||||
}
|
||||
decision, err := domain.NewChannelAntiSpamDecision(
|
||||
22, 33, 44,
|
||||
[]byte(`{"engine":"native-v1","score":0.99}`), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := store.CreateChannelAntiSpamDecision(ctx, decision)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
report, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != decision.EvidenceHash ||
|
||||
reports[0].Items[0].SecondaryID != 33 {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
}
|
||||
89
internal/app/moderation/service.go
Normal file
89
internal/app/moderation/service.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service owns moderation submission invariants. RPC handlers provide
|
||||
// domain-only snapshots; the service canonicalizes and persists them before a
|
||||
// client may observe a successful report response.
|
||||
type Service struct {
|
||||
reports store.ModerationReportStore
|
||||
cases store.ModerationCaseStore
|
||||
registry store.ModerationEvidenceRegistryStore
|
||||
privateMessages privateMessageReader
|
||||
channelMessages channelMessageReader
|
||||
stories storyReader
|
||||
users userReader
|
||||
channels channelPeerReader
|
||||
photos profilePhotoReader
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithMessageReaders(private privateMessageReader, channels channelMessageReader) Option {
|
||||
return func(service *Service) {
|
||||
service.privateMessages = private
|
||||
service.channelMessages = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithStoryReader(stories storyReader) Option {
|
||||
return func(service *Service) {
|
||||
service.stories = stories
|
||||
}
|
||||
}
|
||||
|
||||
func WithPeerReaders(users userReader, channels channelPeerReader) Option {
|
||||
return func(service *Service) {
|
||||
service.users = users
|
||||
service.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfilePhotoReader(photos profilePhotoReader) Option {
|
||||
return func(service *Service) {
|
||||
service.photos = photos
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(reports store.ModerationReportStore, opts ...Option) *Service {
|
||||
service := &Service{reports: reports}
|
||||
if cases, ok := reports.(store.ModerationCaseStore); ok {
|
||||
service.cases = cases
|
||||
}
|
||||
if registry, ok := reports.(store.ModerationEvidenceRegistryStore); ok {
|
||||
service.registry = registry
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) AcceptReport(ctx context.Context, draft domain.ModerationReportDraft) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
report, err := domain.NewModerationReport(draft)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.reports.CreateModerationReport(ctx, report)
|
||||
}
|
||||
|
||||
func (s *Service) Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
if reportID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
return s.reports.GetModerationReport(ctx, reportID)
|
||||
}
|
||||
36
internal/app/moderation/service_test.go
Normal file
36
internal/app/moderation/service_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAcceptReportReturnsDurableRetry(t *testing.T) {
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
draft := domain.ModerationReportDraft{
|
||||
ReporterUserID: 100, Source: domain.ModerationSourceMessagesSpam,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
ItemID: 200, AuthorUserID: 200, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"snapshot":"peer"}`),
|
||||
}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
first, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first created=%v err=%v", created, err)
|
||||
}
|
||||
draft.CreatedAt = draft.CreatedAt.Add(time.Minute)
|
||||
retry, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || created || retry.ID != first.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
|
||||
ctx context.Context,
|
||||
rules domain.PrivacyRules,
|
||||
event domain.UpdateEvent,
|
||||
excludeAuthKeyID [8]byte,
|
||||
excludeSessionID int64,
|
||||
) (domain.UpdateEvent, error) {
|
||||
writer, ok := c.inner.(store.PrivacyUpdateStore)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, err
|
||||
}
|
||||
c.InvalidateOwners(rules.OwnerUserID)
|
||||
_ = c.WarmOwners(ctx, rules.OwnerUserID)
|
||||
return recorded, nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||
return ok && capability.SupportsDurablePrivacyUpdates()
|
||||
}
|
||||
|
||||
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
||||
|
|
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
|
|||
c.cache.Flush()
|
||||
}
|
||||
|
||||
// InvalidateOwners lets Service be registered as the single privacy read-model
|
||||
// cache group: rule snapshots and relationship facts then share one invalidation
|
||||
// lifecycle.
|
||||
func (s *Service) InvalidateOwners(ids ...int64) {
|
||||
if s == nil || s.rules == nil {
|
||||
return
|
||||
}
|
||||
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
|
||||
cache.InvalidateOwners(ids...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
|
||||
if s == nil || s.rules == nil {
|
||||
return nil
|
||||
}
|
||||
if cache, ok := s.rules.(interface {
|
||||
WarmOwners(context.Context, ...int64) error
|
||||
}); ok {
|
||||
return cache.WarmOwners(ctx, ids...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) FlushReadModelCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
|
||||
cache.FlushReadModelCache()
|
||||
}
|
||||
s.flushFactCaches()
|
||||
}
|
||||
|
||||
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
|
||||
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
|
||||
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
|
||||
|
|
|
|||
245
internal/app/privacy/facts.go
Normal file
245
internal/app/privacy/facts.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrivacyViewerFactsTTL = 10 * time.Minute
|
||||
defaultPrivacyMembershipTTL = 24 * time.Hour
|
||||
|
||||
privacyViewerFactsMaxEntries = 8192
|
||||
privacyMembershipMaxEntries = 65536
|
||||
)
|
||||
|
||||
// baseUserProvider returns viewer-independent user facts through the users read
|
||||
// model. Implementations must batch cold misses rather than issue one query per
|
||||
// user.
|
||||
type baseUserProvider interface {
|
||||
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// channelMembershipProvider is the cold loader behind the bounded membership
|
||||
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
|
||||
type channelMembershipProvider interface {
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
}
|
||||
|
||||
type viewerFacts struct {
|
||||
Found bool
|
||||
Bot bool
|
||||
PremiumUntil int64
|
||||
}
|
||||
|
||||
type membershipKey struct {
|
||||
ChatID int64
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type evaluationNeeds struct {
|
||||
viewerBase bool
|
||||
chatIDs []int64
|
||||
}
|
||||
|
||||
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
|
||||
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
|
||||
MaxEntries: privacyViewerFactsMaxEntries,
|
||||
TTL: defaultPrivacyViewerFactsTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
|
||||
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
|
||||
MaxEntries: privacyMembershipMaxEntries,
|
||||
TTL: defaultPrivacyMembershipTTL,
|
||||
KeyString: func(key membershipKey) string {
|
||||
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
|
||||
var needs evaluationNeeds
|
||||
seenChats := make(map[int64]struct{})
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowPremium,
|
||||
domain.PrivacyRuleAllowBots,
|
||||
domain.PrivacyRuleDisallowBots:
|
||||
needs.viewerBase = true
|
||||
case domain.PrivacyRuleAllowChatParticipants,
|
||||
domain.PrivacyRuleDisallowChatParticipants:
|
||||
for _, chatID := range rule.ChatIDs {
|
||||
if chatID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenChats[chatID]; ok {
|
||||
continue
|
||||
}
|
||||
seenChats[chatID] = struct{}{}
|
||||
needs.chatIDs = append(needs.chatIDs, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return needs
|
||||
}
|
||||
|
||||
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
|
||||
if src.viewerBase {
|
||||
dst.viewerBase = true
|
||||
}
|
||||
if len(src.chatIDs) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
|
||||
for _, id := range dst.chatIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range src.chatIDs {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
dst.chatIDs = append(dst.chatIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
|
||||
ids := dedupNonZero(viewerUserIDs)
|
||||
if len(ids) == 0 {
|
||||
return map[int64]viewerFacts{}, nil
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
|
||||
out := make(map[int64]viewerFacts, len(missing))
|
||||
for _, id := range missing {
|
||||
out[id] = viewerFacts{} // negative cache: user was not found.
|
||||
}
|
||||
if s == nil || s.baseUsers == nil {
|
||||
return out, nil
|
||||
}
|
||||
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ID == 0 {
|
||||
continue
|
||||
}
|
||||
out[user.ID] = viewerFacts{
|
||||
Found: true,
|
||||
Bot: user.Bot,
|
||||
PremiumUntil: int64(user.PremiumUntil),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.viewerFacts == nil {
|
||||
return loadMissing(ctx, ids)
|
||||
}
|
||||
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
|
||||
func(int64) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
|
||||
chats := dedupNonZero(chatIDs)
|
||||
viewers := dedupNonZero(viewerUserIDs)
|
||||
if len(chats) == 0 || len(viewers) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
||||
for _, chatID := range chats {
|
||||
for _, viewerID := range viewers {
|
||||
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false // negative cache: not an active member.
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
for chatID, userIDs := range byChat {
|
||||
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range active {
|
||||
out[membershipKey{ChatID: chatID, UserID: userID}] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
}
|
||||
|
||||
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
||||
if ctx == nil || !facts.Found {
|
||||
return
|
||||
}
|
||||
ctx.ViewerIsBot = facts.Bot
|
||||
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
|
||||
}
|
||||
|
||||
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
|
||||
if ctx == nil || len(chatIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, chatID := range chatIDs {
|
||||
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
|
||||
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
|
||||
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
|
||||
if s == nil || s.viewerFacts == nil {
|
||||
return
|
||||
}
|
||||
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
|
||||
}
|
||||
|
||||
// InvalidateMembership invalidates one membership pair after a channel-member change.
|
||||
func (s *Service) InvalidateMembership(channelID, userID int64) {
|
||||
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
|
||||
}
|
||||
|
||||
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
|
||||
func (s *Service) InvalidateChannelMemberships(channelID int64) {
|
||||
if s == nil || s.membershipFacts == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
|
||||
}
|
||||
|
||||
func (s *Service) flushFactCaches() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.viewerFacts != nil {
|
||||
s.viewerFacts.Flush()
|
||||
}
|
||||
if s.membershipFacts != nil {
|
||||
s.membershipFacts.Flush()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,49 @@ package privacy
|
|||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const maxPrivacyRules = 100
|
||||
const (
|
||||
maxPrivacyRules = 100
|
||||
maxPrivacyRuleIDs = 5000
|
||||
)
|
||||
|
||||
// Service owns account privacy rules and viewer-specific evaluation.
|
||||
type Service struct {
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
baseUsers baseUserProvider
|
||||
memberships channelMembershipProvider
|
||||
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
|
||||
membershipFacts *readmodelcache.Cache[membershipKey, bool]
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
||||
return &Service{rules: rules, contacts: contacts}
|
||||
return &Service{
|
||||
rules: rules,
|
||||
contacts: contacts,
|
||||
viewerFacts: newViewerFactsCache(),
|
||||
membershipFacts: newMembershipCache(),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
|
||||
// privacy fact caches. It is called after users/channels services are built to
|
||||
// avoid a package dependency cycle.
|
||||
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.baseUsers = users
|
||||
s.memberships = memberships
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
||||
|
|
@ -43,6 +71,52 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
|||
}
|
||||
|
||||
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
out, err := normalizedRules(ownerUserID, key, rules)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetRulesWithUpdate uses the production atomic write boundary when available.
|
||||
// durable=false means no write was attempted; the RPC layer may then use the
|
||||
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
|
||||
func (s *Service) SetRulesWithUpdate(
|
||||
ctx context.Context,
|
||||
ownerUserID int64,
|
||||
key domain.PrivacyKey,
|
||||
rules []domain.PrivacyRule,
|
||||
date int,
|
||||
excludeAuthKeyID [8]byte,
|
||||
excludeSessionID int64,
|
||||
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
|
||||
out, err := normalizedRules(ownerUserID, key, rules)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||
if !ok || !capability.SupportsDurablePrivacyUpdates() {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
|
||||
}
|
||||
writer := s.rules.(store.PrivacyUpdateStore)
|
||||
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPrivacy,
|
||||
Date: date,
|
||||
Privacy: cloneRules(out),
|
||||
PtsCount: 1,
|
||||
}, excludeAuthKeyID, excludeSessionID)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
return out, event, true, nil
|
||||
}
|
||||
|
||||
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
|
|
@ -52,13 +126,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
|||
if err := validateRules(rules); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
||||
|
|
@ -96,17 +164,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
needs := needsForRules(rules)
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerUserID,
|
||||
ViewerUserID: viewerUserID,
|
||||
}
|
||||
if s != nil && s.contacts != nil {
|
||||
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
evalCtx.ViewerIsContact = true
|
||||
evalCtx.ViewerCloseFriend = contact.CloseFriend
|
||||
}
|
||||
}
|
||||
if needs.viewerBase {
|
||||
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
|
||||
}
|
||||
if len(needs.chatIDs) > 0 {
|
||||
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
|
||||
}
|
||||
return Evaluate(rules, evalCtx), nil
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
|||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
var needs evaluationNeeds
|
||||
for _, owner := range owners {
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[owner][key]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
}
|
||||
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
|
||||
// contacts.Get(owner, viewer))。
|
||||
var reverse map[int64]domain.Contact
|
||||
|
|
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needs.viewerBase {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var membershipFacts map[membershipKey]bool
|
||||
if len(needs.chatIDs) > 0 {
|
||||
var err error
|
||||
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
_, isContact := reverse[owner]
|
||||
contact, isContact := reverse[owner]
|
||||
m := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
rules, ok := rulesByOwner[owner][k]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, k)
|
||||
}
|
||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewerUserID,
|
||||
ViewerIsContact: isContact,
|
||||
})
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewerUserID,
|
||||
ViewerIsContact: isContact,
|
||||
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||
m[k] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
out[owner] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CanContactForFreeBatch evaluates the complete exception predicate for
|
||||
// per-user contact requirements. Contacts are always free because the global
|
||||
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
|
||||
// exceptions beyond that relationship. Both facts come from the in-memory
|
||||
// privacy/contact read models after their bounded cold loads.
|
||||
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
|
||||
owners := dedupNonZero(ownerUserIDs)
|
||||
out := make(map[int64]bool, len(owners))
|
||||
if viewerUserID == 0 || len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
visibility, err := s.CanSeeBatch(
|
||||
ctx,
|
||||
owners,
|
||||
viewerUserID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contacts map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, ownerUserID := range owners {
|
||||
_, isContact := contacts[ownerUserID]
|
||||
out[ownerUserID] = ownerUserID == viewerUserID ||
|
||||
isContact ||
|
||||
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||
|
|
@ -249,6 +400,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
var needs evaluationNeeds
|
||||
for _, owner := range owners {
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[owner][key]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needs.viewerBase {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var membershipFacts map[membershipKey]bool
|
||||
if len(needs.chatIDs) > 0 {
|
||||
var err error
|
||||
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||
var ownerContacts map[int64]domain.Contact
|
||||
|
|
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
perViewer[viewer] = m
|
||||
continue
|
||||
}
|
||||
_, isContact := ownerContacts[viewer]
|
||||
contact, isContact := ownerContacts[viewer]
|
||||
for _, k := range keys {
|
||||
rules, ok := rulesByOwner[owner][k]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, k)
|
||||
}
|
||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewer,
|
||||
ViewerIsContact: isContact,
|
||||
})
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewer,
|
||||
ViewerIsContact: isContact,
|
||||
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||
m[k] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
perViewer[viewer] = m
|
||||
}
|
||||
|
|
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
|
|||
if len(rules) > maxPrivacyRules {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
totalIDs := 0
|
||||
for _, rule := range rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowContacts,
|
||||
|
|
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
|
|||
default:
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
|
||||
if totalIDs > maxPrivacyRuleIDs {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
for _, id := range rule.UserIDs {
|
||||
if id <= 0 {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
for _, id := range rule.ChatIDs {
|
||||
if id <= 0 {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -607,6 +607,19 @@ func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]b
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPrivacy durably synchronizes the exact immutable privacy snapshot to
|
||||
// the account's other sessions and offline difference stream.
|
||||
func (s *Service) RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if rules.OwnerUserID != userID || rules.Key == "" || len(rules.Rules) == 0 {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPrivacy,
|
||||
Privacy: rules,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
|
||||
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
|
||||
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package userprojection
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
|
|
@ -660,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
|||
return domain.User{}, err
|
||||
}
|
||||
if !statusAllowed {
|
||||
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
|
||||
user.LastSeenAt = 0
|
||||
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
|
||||
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
|
||||
}
|
||||
}
|
||||
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
ref.Personal = true
|
||||
|
|
|
|||
|
|
@ -138,6 +138,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
|||
return s.loadBaseUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||
// privacy -> users -> privacy recursion.
|
||||
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue