feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -261,6 +261,41 @@ WHERE user_id = $1`, userID)
return settings, true, nil
}
func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
if err != nil {
return nil, fmt.Errorf("get account settings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int64
settings := domain.DefaultAccountSettings()
gp := &settings.GlobalPrivacy
if err := rows.Scan(
&userID,
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
return nil, fmt.Errorf("scan account settings batch: %w", err)
}
out[userID] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account settings batch: %w", err)
}
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
gp := settings.GlobalPrivacy
if _, err := s.db.Exec(ctx, `

View file

@ -0,0 +1,152 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type AuthDeliveryReportStore struct {
db sqlcgen.DBTX
}
func NewAuthDeliveryReportStore(db sqlcgen.DBTX) *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{db: db}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if s == nil || s.db == nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("begin auth delivery report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('auth-delivery:' || encode($1::bytea, 'hex'), 0)
)`, report.AuthKeyID[:]); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("lock auth delivery reporter: %w", err)
}
existing, found, err := getAuthDeliveryReportByFingerprint(ctx, tx, report.AuthKeyID, report.Fingerprint)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if found {
return existing, false, nil
}
var hourly, phoneDaily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (
WHERE auth_key_id = $1 AND created_at >= $3::timestamptz - interval '1 hour'
),
count(*) FILTER (
WHERE phone_hash = $2 AND created_at >= $3::timestamptz - interval '24 hours'
)
FROM auth_delivery_reports
WHERE created_at <= $3::timestamptz
AND (auth_key_id = $1 OR phone_hash = $2)`,
report.AuthKeyID[:], report.PhoneHash[:], report.CreatedAt,
).Scan(&hourly, &phoneDaily); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("count auth delivery reports: %w", err)
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_delivery_reports (
auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id`,
report.AuthKeyID[:], report.SessionID, report.ClientType,
report.PhoneHash[:], report.CodeHash[:], report.IssuedUserID,
report.DeliveryID, string(report.Channel), report.MNC,
report.Fingerprint[:], report.CreatedAt,
).Scan(&report.ID)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("insert auth delivery report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("commit auth delivery report: %w", err)
}
return report, true, nil
}
func getAuthDeliveryReportByFingerprint(ctx context.Context, db sqlcgen.DBTX, authKeyID [8]byte, fingerprint [32]byte) (domain.AuthDeliveryReport, bool, error) {
var report domain.AuthDeliveryReport
var storedAuthKey, phoneHash, codeHash, storedFingerprint []byte
var channel string
err := db.QueryRow(ctx, `
SELECT id, auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
FROM auth_delivery_reports
WHERE auth_key_id = $1 AND fingerprint = $2`,
authKeyID[:], fingerprint[:],
).Scan(
&report.ID, &storedAuthKey, &report.SessionID, &report.ClientType,
&phoneHash, &codeHash, &report.IssuedUserID, &report.DeliveryID,
&channel, &report.MNC, &storedFingerprint, &report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AuthDeliveryReport{}, false, nil
}
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: %w", err)
}
if len(storedAuthKey) != len(report.AuthKeyID) ||
len(phoneHash) != len(report.PhoneHash) ||
len(codeHash) != len(report.CodeHash) ||
len(storedFingerprint) != len(report.Fingerprint) {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: invalid hash shape")
}
copy(report.AuthKeyID[:], storedAuthKey)
copy(report.PhoneHash[:], phoneHash)
copy(report.CodeHash[:], codeHash)
copy(report.Fingerprint[:], storedFingerprint)
report.Channel = domain.AuthCodeDeliveryKind(channel)
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("validate auth delivery report: %w", err)
}
return report, true, nil
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("auth delivery report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM auth_delivery_reports
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM auth_delivery_reports r
USING doomed d
WHERE r.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired auth delivery reports: %w", err)
}
return int(tag.RowsAffected()), nil
}

View file

@ -0,0 +1,53 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAuthDeliveryReportPostgresIsIdempotentAndRetainedSeparately(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
createdAt := time.Unix(123_456, 0).UTC()
report, err := domain.NewAuthDeliveryReport(
[8]byte{1, 2, 3, 4, 5, 6, 7, byte(time.Now().UnixNano())},
time.Now().UnixNano(), "tdesktop", "15550004444",
"phone-code-hash", 77, "delivery-77",
domain.AuthCodeDeliverySMS, "46000", createdAt,
)
if err != nil {
t.Fatal(err)
}
store := NewAuthDeliveryReportStore(pool)
stored, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("stored=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_delivery_reports WHERE id = $1", stored.ID)
})
retry, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || created || retry.ID != stored.ID ||
retry.PhoneHash != stored.PhoneHash || retry.CodeHash != stored.CodeHash {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
deleted, err := store.DeleteExpiredAuthDeliveryReports(
ctx, createdAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
var moderationRows int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1`, report.IssuedUserID).Scan(&moderationRows); err != nil {
t.Fatal(err)
}
if moderationRows != 0 {
t.Fatalf("auth diagnostic leaked into moderation reports: %d", moderationRows)
}
}

View file

@ -60,6 +60,68 @@ func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.Del
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
}
func (s *ChannelStore) ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if channelID <= 0 || len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if date == 0 {
date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("moderation delete channel messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin moderation delete channel messages: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil || channel.Deleted {
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, ids)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
systemMember := domain.ChannelMember{
ChannelID: channel.ID, UserID: domain.OfficialSystemUserID,
Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive,
}
deleted, event, channel, err := s.deleteChannelMessagesTx(
ctx, tx, channel, systemMember, ids, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
cascades, err := s.cascadeDeleteDiscussionRootsTx(
ctx, tx, refs, deleted, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit moderation delete channel messages: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channel.ID, 0)
for i := range cascades {
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
}
return domain.DeleteChannelMessagesResult{
Channel: channel, Event: event, DeletedIDs: deleted,
Recipients: recipients, DiscussionDeletes: cascades,
}, nil
}
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
id32, _, err := validUniqueChannelMessageIDs(ids)

View file

@ -612,3 +612,48 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
NextOffset: next,
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
if message.Deleted || message.ID <= member.AvailableMinID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, message_id, reacted_user_id, sender_user_id,
reaction_type, reaction_value, big, unread, chosen_order, reaction_date
FROM channel_message_reactions
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3
ORDER BY chosen_order, reaction_type, reaction_value
LIMIT $4`,
req.ChannelID, req.MessageID, req.ReactorUserID,
domain.MaxChannelMessageReactionsPerUser)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err)
}
defer rows.Close()
reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser)
for rows.Next() {
reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
reactions = append(reactions, reaction)
}
if err := rows.Err(); err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
return domain.ChannelMessageReactionLookup{
Channel: channel, Message: message, Reactions: reactions,
}, len(reactions) > 0, nil
}

View file

@ -0,0 +1,149 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ClientTelemetryStore struct {
db sqlcgen.DBTX
}
func NewClientTelemetryStore(db sqlcgen.DBTX) *ClientTelemetryStore {
return &ClientTelemetryStore{db: db}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if s == nil || s.db == nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
}
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("begin client telemetry: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('client-telemetry:' || $1::bigint::text, 0)
)`, event.UserID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("lock client telemetry user: %w", err)
}
existing, found, err := getClientTelemetryByFingerprint(
ctx, tx, event.UserID, event.Fingerprint,
)
if err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
if found {
return existing, false, nil
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM client_telemetry_events
WHERE user_id = $1 AND created_at <= $2::timestamptz`,
event.UserID, event.CreatedAt,
).Scan(&hourly, &daily); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("count client telemetry: %w", err)
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
if err := tx.QueryRow(ctx, `
INSERT INTO client_telemetry_events (
user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8)
RETURNING id`,
event.UserID, string(event.Kind), string(event.Peer.Type),
event.Peer.ID, event.SubjectIDs, []byte(event.Payload),
event.Fingerprint[:], event.CreatedAt,
).Scan(&event.ID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("insert client telemetry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("commit client telemetry: %w", err)
}
return event, true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("client telemetry store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM client_telemetry_events
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM client_telemetry_events e
USING doomed d
WHERE e.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired client telemetry: %w", err)
}
return int(tag.RowsAffected()), nil
}
func getClientTelemetryByFingerprint(ctx context.Context, db sqlcgen.DBTX, userID int64, fingerprint [32]byte) (domain.ClientTelemetryEvent, bool, error) {
var event domain.ClientTelemetryEvent
var kind, peerType string
var payload, storedFingerprint []byte
if err := db.QueryRow(ctx, `
SELECT id, user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
FROM client_telemetry_events
WHERE user_id = $1 AND fingerprint = $2`,
userID, fingerprint[:],
).Scan(
&event.ID, &event.UserID, &kind, &peerType, &event.Peer.ID,
&event.SubjectIDs, &payload, &storedFingerprint, &event.CreatedAt,
); errors.Is(err, pgx.ErrNoRows) {
return domain.ClientTelemetryEvent{}, false, nil
} else if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("get client telemetry: %w", err)
}
event.Kind = domain.ClientTelemetryKind(kind)
event.Peer.Type = domain.PeerType(peerType)
var canonicalPayload map[string]any
if err := json.Unmarshal(payload, &canonicalPayload); err != nil || canonicalPayload == nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
canonicalRaw, marshalErr := json.Marshal(canonicalPayload)
if marshalErr != nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
event.Payload = canonicalRaw
if len(storedFingerprint) != len(event.Fingerprint) {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
copy(event.Fingerprint[:], storedFingerprint)
if err := event.Validate(); err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
return event, true, nil
}

View file

@ -1,11 +1,13 @@
package postgres
import (
"bytes"
"context"
"encoding/json"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
@ -47,3 +49,69 @@ ON CONFLICT (
}
return tag.RowsAffected() == 1, nil
}
func (s *EphemeralReportStore) ListUnmigratedEphemeralReports(ctx context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("ephemeral report store is not configured")
}
if limit <= 0 || limit > 1000 {
return nil, fmt.Errorf("legacy ephemeral report batch limit out of range")
}
rows, err := s.db.Query(ctx, `
SELECT r.id, r.reporter_user_id, r.channel_id, r.ephemeral_message_id,
r.sender_user_id, r.receiver_user_id, r.report_option,
r.report_comment, r.comment_hash, r.payload_hash, r.evidence,
r.created_at
FROM ephemeral_abuse_reports r
LEFT JOIN moderation_legacy_ephemeral_migrations m
ON m.legacy_report_id = r.id
WHERE m.legacy_report_id IS NULL
ORDER BY r.id
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("list unmigrated ephemeral reports: %w", err)
}
defer rows.Close()
out := make([]store.LegacyEphemeralReport, 0, limit)
for rows.Next() {
var (
legacy store.LegacyEphemeralReport
channelID, senderUserID, receiverUserID int64
messageID int
commentHash, payloadHash, evidenceRaw []byte
)
if err := rows.Scan(
&legacy.ID, &legacy.Report.ReporterUserID, &channelID,
&messageID, &senderUserID, &receiverUserID,
&legacy.Report.Option, &legacy.Report.Comment, &commentHash,
&payloadHash, &evidenceRaw, &legacy.Report.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan legacy ephemeral report: %w", err)
}
if legacy.ID <= 0 || len(commentHash) != len(legacy.Report.CommentHash) ||
len(payloadHash) != len(legacy.Report.Evidence.PayloadHash) {
return nil, fmt.Errorf("legacy ephemeral report %d has invalid persisted identity", legacy.ID)
}
copy(legacy.Report.CommentHash[:], commentHash)
if err := json.Unmarshal(evidenceRaw, &legacy.Report.Evidence); err != nil {
return nil, fmt.Errorf("decode legacy ephemeral report %d evidence: %w", legacy.ID, err)
}
if legacy.Report.Evidence.Peer.Type != domain.PeerTypeChannel ||
legacy.Report.Evidence.Peer.ID != channelID ||
legacy.Report.Evidence.MessageID != messageID ||
legacy.Report.Evidence.SenderUserID != senderUserID ||
legacy.Report.Evidence.ReceiverUserID != receiverUserID ||
!bytes.Equal(legacy.Report.Evidence.PayloadHash[:], payloadHash) {
return nil, fmt.Errorf("legacy ephemeral report %d evidence disagrees with indexed columns", legacy.ID)
}
if err := legacy.Report.Validate(); err != nil {
return nil, fmt.Errorf("validate legacy ephemeral report %d: %w", legacy.ID, err)
}
out = append(out, legacy)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy ephemeral reports: %w", err)
}
return out, nil
}

View file

@ -0,0 +1,441 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ModerationReportStore struct {
db sqlcgen.DBTX
}
func NewModerationReportStore(db sqlcgen.DBTX) *ModerationReportStore {
return &ModerationReportStore{db: db}
}
func (s *ModerationReportStore) CreateModerationReport(ctx context.Context, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin moderation report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit moderation report: %w", err)
}
return stored, created, nil
}
func createModerationReportTx(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
var (
reportID int64
err error
)
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
err = tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup moderation report fingerprint: %w", err)
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM moderation_reports
WHERE reporter_user_id = $1
AND created_at <= $2::timestamptz`,
report.ReporterUserID, report.CreatedAt).Scan(&hourly, &daily); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("count moderation reporter submissions: %w", err)
}
if hourly >= domain.MaxModerationReportsPerHour || daily >= domain.MaxModerationReportsPerDay {
return domain.ModerationReport{}, false, domain.ErrModerationRateLimited
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !created {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
func (s *ModerationReportStore) ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if legacyReportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
if report.Source != domain.ModerationSourceEphemeral {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin legacy ephemeral report import: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-legacy-ephemeral:' || $1::bigint::text, 0)
)`, legacyReportID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock legacy ephemeral report: %w", err)
}
var reportID int64
err = tx.QueryRow(ctx, `
SELECT moderation_report_id
FROM moderation_legacy_ephemeral_migrations
WHERE legacy_report_id = $1`, legacyReportID).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("legacy ephemeral report mapping points to missing moderation report")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup legacy ephemeral report mapping: %w", err)
}
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_legacy_ephemeral_migrations (
legacy_report_id, moderation_report_id, migrated_at
) VALUES ($1,$2,clock_timestamp())`,
legacyReportID, reportID,
); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("insert legacy ephemeral report mapping: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit legacy ephemeral report import: %w", err)
}
if created {
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
existing, found, err := getModerationReport(ctx, s.db, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("imported duplicate moderation report disappeared")
}
return existing, false, nil
}
func insertModerationReport(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (int64, bool, error) {
var reportID int64
err := tx.QueryRow(ctx, `
INSERT INTO moderation_reports (
reporter_user_id, source, target_peer_type, target_peer_id, reason,
report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (reporter_user_id, fingerprint) DO NOTHING
RETURNING id`,
report.ReporterUserID, string(report.Source), string(report.Target.Type),
report.Target.ID, string(report.Reason), report.Option, report.Comment,
report.CommentHash[:], report.Fingerprint[:], report.TaxonomyVersion,
report.CreatedAt,
).Scan(&reportID)
if errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID); err != nil {
return 0, false, fmt.Errorf("lookup duplicate moderation report: %w", err)
}
return reportID, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("insert moderation report: %w", err)
}
for ordinal, item := range report.Items {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_report_items (
report_id, ordinal, item_kind, peer_type, peer_id, item_id,
secondary_id, author_user_id, evidence_schema_version, evidence,
evidence_hash
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11)`,
reportID, ordinal, string(item.Kind), string(item.Peer.Type),
item.Peer.ID, item.ItemID, item.SecondaryID, item.AuthorUserID,
item.EvidenceSchemaVersion, []byte(item.Evidence), item.EvidenceHash[:],
); err != nil {
return 0, false, fmt.Errorf("insert moderation report item %d: %w", ordinal, err)
}
}
for _, hold := range report.MediaHolds {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_media_holds (
report_id, item_ordinal, media_kind, storage_key, created_at
) VALUES ($1,$2,$3,$4,$5)`,
reportID, hold.ItemIndex, string(hold.Kind), hold.StorageKey,
report.CreatedAt,
); err != nil {
return 0, false, fmt.Errorf("insert moderation media hold: %w", err)
}
}
if err := attachModerationReportToCase(ctx, tx, reportID, report); err != nil {
return 0, false, err
}
return reportID, true, nil
}
func attachModerationReportToCase(ctx context.Context, tx pgx.Tx, reportID int64, report domain.ModerationReport) error {
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended(
'moderation-case:' || $1::text || ':' || $2::bigint::text,
0
)
)`, string(report.Target.Type), report.Target.ID); err != nil {
return fmt.Errorf("lock moderation case target: %w", err)
}
var caseID int64
err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_cases
WHERE target_peer_type = $1
AND target_peer_id = $2
AND status IN ('open', 'in_review')
FOR UPDATE`,
string(report.Target.Type), report.Target.ID,
).Scan(&caseID)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, assigned_to,
version, report_count, distinct_reporter_count, first_report_at,
last_report_at, created_at, updated_at
) VALUES ($1,$2,'open',$3,'',1,1,1,$4,$4,$4,$4)
RETURNING id`,
string(report.Target.Type), report.Target.ID,
int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
).Scan(&caseID)
if err != nil {
return fmt.Errorf("create moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to new moderation case: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("find active moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE moderation_cases c
SET severity = greatest(c.severity, $2),
version = c.version + 1,
report_count = (
SELECT count(*)::integer
FROM moderation_case_reports cr
WHERE cr.case_id = c.id
),
distinct_reporter_count = (
SELECT count(DISTINCT r.reporter_user_id)::integer
FROM moderation_case_reports cr
JOIN moderation_reports r ON r.id = cr.report_id
WHERE cr.case_id = c.id
),
first_report_at = least(c.first_report_at, $3),
last_report_at = greatest(c.last_report_at, $3),
updated_at = greatest(c.updated_at, $3)
WHERE c.id = $1`,
caseID, int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
); err != nil {
return fmt.Errorf("update moderation case aggregates: %w", err)
}
return nil
}
func (s *ModerationReportStore) GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if reportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
return getModerationReport(ctx, s.db, reportID)
}
func getModerationReport(ctx context.Context, db sqlcgen.DBTX, reportID int64) (domain.ModerationReport, bool, error) {
var (
report domain.ModerationReport
source, target, reason string
commentHash []byte
fingerprint []byte
)
err := db.QueryRow(ctx, `
SELECT id, reporter_user_id, source, target_peer_type, target_peer_id,
reason, report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
FROM moderation_reports
WHERE id = $1`, reportID).Scan(
&report.ID, &report.ReporterUserID, &source, &target,
&report.Target.ID, &reason, &report.Option, &report.Comment,
&commentHash, &fingerprint, &report.TaxonomyVersion,
&report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, nil
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: %w", err)
}
if len(commentHash) != len(report.CommentHash) || len(fingerprint) != len(report.Fingerprint) {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: invalid persisted hash length")
}
copy(report.CommentHash[:], commentHash)
copy(report.Fingerprint[:], fingerprint)
report.Source = domain.ModerationReportSource(source)
report.Target.Type = domain.PeerType(target)
report.Reason = domain.ModerationReason(reason)
rows, err := db.Query(ctx, `
SELECT ordinal, item_kind, peer_type, peer_id, item_id, secondary_id,
author_user_id, evidence_schema_version, evidence, evidence_hash
FROM moderation_report_items
WHERE report_id = $1
ORDER BY ordinal`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation report items: %w", err)
}
for rows.Next() {
var (
ordinal int
item domain.ModerationReportItem
kind, peerType string
evidence, evidenceHash []byte
)
if err := rows.Scan(
&ordinal, &kind, &peerType, &item.Peer.ID, &item.ItemID,
&item.SecondaryID, &item.AuthorUserID,
&item.EvidenceSchemaVersion, &evidence, &evidenceHash,
); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: %w", err)
}
if ordinal != len(report.Items) || len(evidenceHash) != len(item.EvidenceHash) {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: invalid persisted ordering or hash")
}
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item evidence: %w", err)
}
item.Kind = domain.ModerationReportItemKind(kind)
item.Peer.Type = domain.PeerType(peerType)
item.Evidence = canonical
copy(item.EvidenceHash[:], evidenceHash)
report.Items = append(report.Items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation report items: %w", err)
}
rows.Close()
holdRows, err := db.Query(ctx, `
SELECT item_ordinal, media_kind, storage_key
FROM moderation_media_holds
WHERE report_id = $1
ORDER BY item_ordinal, media_kind, storage_key`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation media holds: %w", err)
}
defer holdRows.Close()
for holdRows.Next() {
var hold domain.ModerationMediaHold
var kind string
if err := holdRows.Scan(&hold.ItemIndex, &kind, &hold.StorageKey); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation media hold: %w", err)
}
hold.Kind = domain.ModerationMediaKind(kind)
report.MediaHolds = append(report.MediaHolds, hold)
}
if err := holdRows.Err(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation media holds: %w", err)
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("validate persisted moderation report: %w", err)
}
return report, true, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,471 @@
package postgres
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
moderationapp "telesrv/internal/app/moderation"
"telesrv/internal/domain"
)
func TestModerationReportStoreAtomicEvidenceAndIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
reporter := now.UnixNano()&0x3fffffff + 5_000
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceProfilePhoto,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1},
Reason: domain.ModerationReasonFake, Option: "v1/fake",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemProfilePhoto,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1},
ItemID: reporter + 2, AuthorUserID: reporter + 1,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"photo_id":2,"owner_id":1}`),
}},
MediaHolds: []domain.ModerationMediaHold{{
ItemIndex: 0, Kind: domain.ModerationMediaPhoto,
StorageKey: "profile/photo/test",
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore(pool)
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create=%v err=%v", created, err)
}
t.Cleanup(func() {
cleanupModerationReport(t, pool, stored.ID)
})
retry, created, err := store.CreateModerationReport(ctx, report)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
got, found, err := store.GetModerationReport(ctx, stored.ID)
if err != nil || !found {
t.Fatalf("get found=%v err=%v", found, err)
}
if got.Fingerprint != report.Fingerprint || len(got.Items) != 1 ||
len(got.MediaHolds) != 1 || got.MediaHolds[0].StorageKey != "profile/photo/test" {
t.Fatalf("stored report = %+v", got)
}
var reports, items, holds int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM moderation_reports WHERE id = $1),
(SELECT count(*) FROM moderation_report_items WHERE report_id = $1),
(SELECT count(*) FROM moderation_media_holds WHERE report_id = $1)`,
stored.ID).Scan(&reports, &items, &holds); err != nil {
t.Fatal(err)
}
if reports != 1 || items != 1 || holds != 1 {
t.Fatalf("rows reports=%d items=%d holds=%d", reports, items, holds)
}
}
func TestModerationSponsoredReportIsAtomicUnderConcurrentFinalOptions(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
userID := now.UnixNano()&0x3fffffff + 8_000
randomID := []byte("postgres-sponsored-random-id")
store := NewModerationReportStore(pool)
impression, err := domain.NewSponsoredMessageImpression(
userID, randomID,
domain.Peer{Type: domain.PeerTypeChannel, ID: userID + 1},
userID+2, []byte(`{"creative_id":"pg-creative","schema_version":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)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM sponsored_message_impressions WHERE id = $1", impression.ID)
})
service := moderationapp.NewService(store)
type result struct {
report domain.ModerationReport
created bool
err error
}
start := make(chan struct{})
results := make(chan result, 2)
var wg sync.WaitGroup
for _, option := range []struct {
reason domain.ModerationReason
option string
}{
{domain.ModerationReasonSpam, "spam"},
{domain.ModerationReasonFake, "fake"},
} {
wg.Add(1)
go func(reason domain.ModerationReason, option string) {
defer wg.Done()
<-start
report, created, err := service.ReportSponsored(
ctx, userID, randomID, reason, option, now.Add(time.Second),
)
results <- result{report: report, created: created, err: err}
}(option.reason, option.option)
}
close(start)
wg.Wait()
close(results)
var reportID int64
var createdCount int
for got := range results {
if got.err != nil || got.report.ID <= 0 {
t.Fatalf("concurrent result=%+v", got)
}
if reportID == 0 {
reportID = got.report.ID
} else if got.report.ID != reportID {
t.Fatalf("report ids differ: %d vs %d", reportID, got.report.ID)
}
if got.created {
createdCount++
}
}
if createdCount != 1 {
t.Fatalf("created count=%d, want 1", createdCount)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, reportID) })
var reportCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1 AND source = 'sponsored'`,
userID,
).Scan(&reportCount); err != nil {
t.Fatal(err)
}
if reportCount != 1 {
t.Fatalf("sponsored reports=%d, want 1", reportCount)
}
}
func TestModerationCaseActionAppealLinkAndTelemetryPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
reporter := now.UnixNano()&0x3fffffff + 12_000
target := domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1}
store := NewModerationReportStore(pool)
service := moderationapp.NewService(store)
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, 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,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("report=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, stored.ID) })
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{
Target: target, Limit: 10,
})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "pg-reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "pg-reviewer", Reason: "confirmed fake",
CommandID: "pg-moderation-decision-" + time.Unix(0, reporter).Format("150405.000000000"),
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || !created {
t.Fatalf("decision created=%v err=%v", created, err)
}
actions, err := store.ClaimModerationActions(
ctx, now.Add(3*time.Second), 10, time.Minute,
)
if err != nil || len(actions) != 1 {
t.Fatalf("actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(4*time.Second),
); err != nil {
t.Fatal(err)
}
token, err := service.IssueAppealLink(
ctx, cases[0].ID, target.ID, now.Add(time.Hour), now.Add(5*time.Second),
)
if err != nil {
t.Fatal(err)
}
appeal, created, err := service.SubmitAppealLink(
ctx, token, "Postgres appeal.", now.Add(6*time.Second),
)
if err != nil || !created || appeal.ID <= 0 {
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
}
retry, created, err := service.SubmitAppealLink(
ctx, token, "retry body", now.Add(7*time.Second),
)
if err != nil || created || retry.ID != appeal.ID ||
retry.Text != appeal.Text {
t.Fatalf("appeal retry=%+v created=%v err=%v", retry, created, err)
}
telemetryStore := NewClientTelemetryStore(pool)
telemetryAt := time.Unix(reporter%1_000_000+1, 0).UTC()
event, err := domain.NewClientTelemetryEvent(
reporter, domain.ClientTelemetryMessageDelivery, target,
[]int64{3, 1, 2}, map[string]any{"push": true}, telemetryAt,
)
if err != nil {
t.Fatal(err)
}
telemetry, created, err := telemetryStore.CreateClientTelemetry(ctx, event)
if err != nil || !created || telemetry.ID <= 0 {
t.Fatalf("telemetry=%+v created=%v err=%v", telemetry, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM client_telemetry_events WHERE id = $1", telemetry.ID)
})
retryTelemetry, created, err := telemetryStore.CreateClientTelemetry(ctx, event)
if err != nil || created || retryTelemetry.ID != telemetry.ID {
t.Fatalf("telemetry retry=%+v created=%v err=%v", retryTelemetry, created, err)
}
deleted, err := telemetryStore.DeleteExpiredClientTelemetry(
ctx, telemetryAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("telemetry retention deleted=%d err=%v", deleted, err)
}
}
func TestModerationSanctionSupersessionAndAppealOwnershipPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
store := NewModerationReportStore(pool)
service := moderationapp.NewService(store)
base := now.UnixNano()&0x3fffffff + 40_000
createDecision := func(target domain.Peer, reporter int64, option, command string, at time.Time) (int64, int64) {
t.Helper()
report, _, 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,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, report.ID) })
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, "pg-owner", at.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "pg-owner", Reason: "confirmed", CommandID: command,
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: at.Add(2 * time.Second),
})
if err != nil || len(detail.Actions) != 1 {
t.Fatalf("decision=%+v err=%v", detail, err)
}
return claimed.ID, detail.Actions[0].ID
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: base + 1}
oldCaseID, oldActionID := createDecision(target, base+2, "old", "pg-old", now)
newCaseID, newActionID := createDecision(target, base+3, "new", "pg-new", now.Add(3*time.Second))
claimedActions, err := store.ClaimModerationActions(ctx, now.Add(6*time.Second), 10, time.Minute)
if err != nil {
t.Fatal(err)
}
claimedByID := make(map[int64]domain.ModerationAction, len(claimedActions))
for _, action := range claimedActions {
claimedByID[action.ID] = action
}
oldAction, oldFound := claimedByID[oldActionID]
newAction, newFound := claimedByID[newActionID]
if !oldFound || !newFound {
t.Fatalf("claimed actions=%+v", claimedActions)
}
if current, err := store.IsModerationActionCurrent(ctx, oldAction); err != nil || current {
t.Fatalf("old current=%v err=%v", current, err)
}
if current, err := store.IsModerationActionCurrent(ctx, newAction); err != nil || !current {
t.Fatalf("new current=%v err=%v", current, err)
}
if err := store.SupersedeModerationAction(
ctx, oldAction.ID, oldAction.Attempts, now.Add(7*time.Second),
); err != nil {
t.Fatal(err)
}
if err := store.CompleteModerationAction(
ctx, newAction.ID, newAction.Attempts, true, "", time.Time{},
now.Add(8*time.Second),
); err != nil {
t.Fatal(err)
}
oldDetail, _, err := service.Case(ctx, oldCaseID)
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
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 ||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
t.Fatalf("new detail=%+v err=%v", newDetail, err)
}
appealTarget := domain.Peer{Type: domain.PeerTypeUser, ID: base + 10}
appealedCaseID, appealedActionID := createDecision(
appealTarget, base+11, "appealed", "pg-appealed", now.Add(10*time.Second),
)
actions, err := store.ClaimModerationActions(ctx, now.Add(13*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 || actions[0].ID != appealedActionID {
t.Fatalf("appealed action=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "", time.Time{},
now.Add(14*time.Second),
); err != nil {
t.Fatal(err)
}
appeal, _, err := service.SubmitAppeal(
ctx, appealedCaseID, appealTarget.ID, "please review", now.Add(15*time.Second),
)
if err != nil {
t.Fatal(err)
}
_, _ = createDecision(
appealTarget, base+12, "newer", "pg-newer-owner", now.Add(16*time.Second),
)
appealedDetail, _, err := service.Case(ctx, appealedCaseID)
if err != nil {
t.Fatal(err)
}
appealClaim, err := service.ClaimCase(
ctx, appealedCaseID, appealedDetail.Case.Version, "pg-owner", now.Add(19*time.Second),
)
if err != nil {
t.Fatal(err)
}
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
CaseID: appealedCaseID, AppealID: appeal.ID,
ExpectedVersion: appealClaim.Version, Actor: "pg-owner",
Reason: "grant", CommandID: "pg-stale-appeal",
Kind: domain.ModerationDecisionAppealGrant,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(20 * time.Second),
})
if !errors.Is(err, domain.ErrModerationActionConflict) {
t.Fatalf("ReviewAppeal error=%v", err)
}
}
func cleanupModerationReport(t *testing.T, pool *pgxpool.Pool, reportID int64) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Errorf("begin moderation cleanup: %v", err)
return
}
defer func() { _ = tx.Rollback(ctx) }()
var caseID int64
err = tx.QueryRow(ctx, `
SELECT case_id FROM moderation_case_reports WHERE report_id = $1`,
reportID,
).Scan(&caseID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("find moderation cleanup case: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM sponsored_message_impressions WHERE report_id = $1", reportID); err != nil {
t.Errorf("cleanup sponsored impression: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM channel_antispam_decisions WHERE report_id = $1", reportID); err != nil {
t.Errorf("cleanup anti-spam decision: %v", err)
return
}
if caseID > 0 {
for _, statement := range []string{
"DELETE FROM moderation_actions WHERE case_id = $1",
"DELETE FROM moderation_decisions WHERE case_id = $1",
"DELETE FROM moderation_appeal_links WHERE case_id = $1",
"DELETE FROM moderation_appeals WHERE case_id = $1",
"DELETE FROM moderation_case_reports WHERE case_id = $1",
"DELETE FROM moderation_cases WHERE id = $1",
} {
if _, err := tx.Exec(ctx, statement, caseID); err != nil {
t.Errorf("moderation cleanup %q: %v", statement, err)
return
}
}
}
if _, err := tx.Exec(ctx, "DELETE FROM moderation_legacy_ephemeral_migrations WHERE moderation_report_id = $1", reportID); err != nil {
t.Errorf("cleanup legacy moderation mapping: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM moderation_reports WHERE id = $1", reportID); err != nil {
t.Errorf("cleanup moderation report: %v", err)
return
}
if err := tx.Commit(ctx); err != nil {
t.Errorf("commit moderation cleanup: %v", err)
}
}

View file

@ -0,0 +1,353 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *ModerationReportStore) CreateSponsoredMessageImpression(ctx context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error) {
if s == nil || s.db == nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := impression.Validate(); err != nil || impression.ID != 0 ||
impression.ReportID != 0 {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
err := s.db.QueryRow(ctx, `
INSERT INTO sponsored_message_impressions (
user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
created_at, expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10)
ON CONFLICT (user_id, random_id_hash) DO NOTHING
RETURNING id`,
impression.UserID, impression.RandomIDHash[:],
string(impression.Target.Type), impression.Target.ID,
impression.AuthorUserID, impression.EvidenceSchemaVersion,
[]byte(impression.Evidence), impression.EvidenceHash[:],
impression.CreatedAt, impression.ExpiresAt,
).Scan(&impression.ID)
if err == nil {
return impression, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("insert sponsored impression: %w", err)
}
existing, found, err := s.GetSponsoredMessageImpression(
ctx, impression.UserID, impression.RandomIDHash, impression.CreatedAt,
)
if err != nil {
return domain.SponsoredMessageImpression{}, false, err
}
if !found || existing.Target != impression.Target ||
existing.AuthorUserID != impression.AuthorUserID ||
existing.EvidenceHash != impression.EvidenceHash ||
!existing.ExpiresAt.Equal(impression.ExpiresAt) {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationActionConflict
}
return existing, false, nil
}
func (s *ModerationReportStore) GetSponsoredMessageImpression(ctx context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error) {
if s == nil || s.db == nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("moderation report store is not configured")
}
if userID <= 0 || randomIDHash == ([32]byte{}) || now.IsZero() {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
impression, err := scanSponsoredMessageImpression(s.db.QueryRow(ctx, `
SELECT id, user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
report_id, created_at, expires_at
FROM sponsored_message_impressions
WHERE user_id = $1 AND random_id_hash = $2 AND expires_at > $3`,
userID, randomIDHash[:], now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.SponsoredMessageImpression{}, false, nil
}
if err != nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("get sponsored impression: %w", err)
}
return impression, true, nil
}
func (s *ModerationReportStore) CreateSponsoredModerationReport(ctx context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if impressionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin sponsored moderation report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
impression, err := scanSponsoredMessageImpression(tx.QueryRow(ctx, `
SELECT id, user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
report_id, created_at, expires_at
FROM sponsored_message_impressions
WHERE id = $1
FOR UPDATE`, impressionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock sponsored impression: %w", err)
}
if !report.CreatedAt.Before(impression.ExpiresAt) {
return domain.ModerationReport{}, false, domain.ErrModerationImpressionExpired
}
if err := domain.ValidateSponsoredModerationReport(impression, report); err != nil {
return domain.ModerationReport{}, false, err
}
if impression.ReportID > 0 {
existing, found, err := getModerationReport(ctx, tx, impression.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return existing, false, nil
}
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
tag, err := tx.Exec(ctx, `
UPDATE sponsored_message_impressions
SET report_id = $2
WHERE id = $1 AND report_id IS NULL`, impressionID, stored.ID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("link sponsored report: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.ModerationReport{}, false, domain.ErrModerationActionConflict
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit sponsored moderation report: %w", err)
}
return stored, created, nil
}
func (s *ModerationReportStore) CreateChannelAntiSpamDecision(ctx context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error) {
if s == nil || s.db == nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := decision.Validate(); err != nil || decision.ID != 0 ||
decision.ReportID != 0 {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
err := s.db.QueryRow(ctx, `
INSERT INTO channel_antispam_decisions (
channel_id, message_id, author_user_id, evidence_schema_version,
evidence, evidence_hash, created_at
) VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)
ON CONFLICT (channel_id, message_id) DO NOTHING
RETURNING id`,
decision.ChannelID, decision.MessageID, decision.AuthorUserID,
decision.EvidenceSchemaVersion, []byte(decision.Evidence),
decision.EvidenceHash[:], decision.CreatedAt,
).Scan(&decision.ID)
if err == nil {
return decision, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("insert anti-spam decision: %w", err)
}
existing, found, err := s.GetChannelAntiSpamDecision(
ctx, decision.ChannelID, decision.MessageID,
)
if err != nil {
return domain.ChannelAntiSpamDecision{}, false, err
}
if !found || existing.AuthorUserID != decision.AuthorUserID ||
existing.EvidenceHash != decision.EvidenceHash {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationActionConflict
}
return existing, false, nil
}
func (s *ModerationReportStore) GetChannelAntiSpamDecision(ctx context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error) {
if s == nil || s.db == nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("moderation report store is not configured")
}
if channelID <= 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
decision, err := scanChannelAntiSpamDecision(s.db.QueryRow(ctx, `
SELECT id, channel_id, message_id, author_user_id,
evidence_schema_version, evidence, evidence_hash, report_id,
created_at
FROM channel_antispam_decisions
WHERE channel_id = $1 AND message_id = $2`, channelID, messageID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelAntiSpamDecision{}, false, nil
}
if err != nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("get anti-spam decision: %w", err)
}
return decision, true, nil
}
func (s *ModerationReportStore) CreateAntiSpamFalsePositiveReport(ctx context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if decisionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin anti-spam false-positive report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
decision, err := scanChannelAntiSpamDecision(tx.QueryRow(ctx, `
SELECT id, channel_id, message_id, author_user_id,
evidence_schema_version, evidence, evidence_hash, report_id,
created_at
FROM channel_antispam_decisions
WHERE id = $1
FOR UPDATE`, decisionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock anti-spam decision: %w", err)
}
if err := domain.ValidateAntiSpamFalsePositiveReport(decision, report); err != nil {
return domain.ModerationReport{}, false, err
}
if decision.ReportID > 0 {
existing, found, err := getModerationReport(ctx, tx, decision.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return existing, false, nil
}
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
tag, err := tx.Exec(ctx, `
UPDATE channel_antispam_decisions
SET report_id = $2
WHERE id = $1 AND report_id IS NULL`, decisionID, stored.ID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("link anti-spam report: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.ModerationReport{}, false, domain.ErrModerationActionConflict
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit anti-spam false-positive report: %w", err)
}
return stored, created, nil
}
func (s *ModerationReportStore) DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("moderation report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrModerationReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM sponsored_message_impressions
WHERE expires_at < $1
ORDER BY expires_at, id
LIMIT $2
)
DELETE FROM sponsored_message_impressions i
USING doomed d
WHERE i.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired sponsored impressions: %w", err)
}
return int(tag.RowsAffected()), nil
}
func scanSponsoredMessageImpression(row moderationCaseScanner) (domain.SponsoredMessageImpression, error) {
var impression domain.SponsoredMessageImpression
var randomIDHash, evidence, evidenceHash []byte
var peerType string
var reportID *int64
if err := row.Scan(
&impression.ID, &impression.UserID, &randomIDHash, &peerType,
&impression.Target.ID, &impression.AuthorUserID,
&impression.EvidenceSchemaVersion, &evidence, &evidenceHash,
&reportID, &impression.CreatedAt, &impression.ExpiresAt,
); err != nil {
return domain.SponsoredMessageImpression{}, err
}
if len(randomIDHash) != len(impression.RandomIDHash) ||
len(evidenceHash) != len(impression.EvidenceHash) {
return domain.SponsoredMessageImpression{}, domain.ErrModerationReportInvalid
}
copy(impression.RandomIDHash[:], randomIDHash)
copy(impression.EvidenceHash[:], evidenceHash)
impression.Target.Type = domain.PeerType(peerType)
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
return domain.SponsoredMessageImpression{}, err
}
impression.Evidence = canonical
if reportID != nil {
impression.ReportID = *reportID
}
if err := impression.Validate(); err != nil {
return domain.SponsoredMessageImpression{}, err
}
return impression, nil
}
func scanChannelAntiSpamDecision(row moderationCaseScanner) (domain.ChannelAntiSpamDecision, error) {
var decision domain.ChannelAntiSpamDecision
var evidence, evidenceHash []byte
var reportID *int64
if err := row.Scan(
&decision.ID, &decision.ChannelID, &decision.MessageID,
&decision.AuthorUserID, &decision.EvidenceSchemaVersion,
&evidence, &evidenceHash, &reportID, &decision.CreatedAt,
); err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
if len(evidenceHash) != len(decision.EvidenceHash) {
return domain.ChannelAntiSpamDecision{}, domain.ErrModerationReportInvalid
}
copy(decision.EvidenceHash[:], evidenceHash)
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
decision.Evidence = canonical
if reportID != nil {
decision.ReportID = *reportID
}
if err := decision.Validate(); err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
return decision, nil
}

View file

@ -24,6 +24,16 @@ func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore {
return &PrivacyStore{db: db}
}
func (s *PrivacyStore) SupportsDurablePrivacyUpdates() bool {
if s == nil {
return false
}
_, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
return ok
}
func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT rules::text
@ -46,11 +56,15 @@ WHERE owner_user_id = $1
}
func (s *PrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
return setPrivacyRules(ctx, s.db, rules)
}
func setPrivacyRules(ctx context.Context, db sqlcgen.DBTX, rules domain.PrivacyRules) error {
raw, err := json.Marshal(rules.Rules)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `
_, err = db.Exec(ctx, `
INSERT INTO account_privacy_rules (owner_user_id, privacy_key, rules, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
@ -63,6 +77,56 @@ ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
return nil
}
// SetPrivacyRulesWithUpdate commits the mutable rule row and the immutable
// account update snapshot in one transaction. A privacy rule can therefore
// never become visible without a matching pts event/outbox item.
func (s *PrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
return domain.UpdateEvent{}, fmt.Errorf("privacy update transaction unavailable")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("begin privacy update: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := setPrivacyRules(ctx, tx, rules); err != nil {
return domain.UpdateEvent{}, err
}
if event.Date == 0 {
return domain.UpdateEvent{}, fmt.Errorf("privacy update date is required")
}
event.Type = domain.UpdateEventPrivacy
event.Privacy = rules
event.PtsCount = 1
qtx := sqlcgen.New(tx)
recorded, err := NewUpdateEventStore(tx).appendInTx(
ctx, tx, qtx, rules.OwnerUserID, event, true,
excludeAuthKeyID, excludeSessionID, true,
)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("append privacy update: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.UpdateEvent{}, fmt.Errorf("commit privacy update: %w", err)
}
committed = true
return recorded, nil
}
func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
if len(ownerUserIDs) == 0 || len(keys) == 0 {
return nil, nil

View file

@ -101,6 +101,15 @@ type PrivacyReadModelWarmer interface {
WarmOwners(context.Context, ...int64) error
}
type PrivacyViewerFactsReadModelCache interface {
InvalidateViewerFacts(...int64)
}
type PrivacyMembershipReadModelCache interface {
InvalidateMembership(channelID, userID int64)
InvalidateChannelMemberships(channelID int64)
}
type ProfilePhotoReadModelCache interface {
InvalidateOwner(domain.PeerType, int64)
FlushReadModelCache()
@ -358,6 +367,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
if l.caches.BotProfiles != nil {
l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID)
}
if cache, ok := l.caches.Privacy.(PrivacyViewerFactsReadModelCache); ok {
cache.InvalidateViewerFacts(evt.PeerID)
}
}
case "user_visibility":
if evt.PeerType == "user" && evt.PeerID != 0 {
@ -457,6 +469,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
if l.caches.RPCProjections != nil {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID)
}
if cache, ok := l.caches.Privacy.(PrivacyMembershipReadModelCache); ok {
cache.InvalidateChannelMemberships(evt.PeerID)
}
}
case "channel_media_counts":
if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelMediaCounts != nil {
@ -487,6 +502,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForPeer(evt.OwnerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID})
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
}
if cache, ok := l.caches.Privacy.(PrivacyMembershipReadModelCache); ok {
cache.InvalidateMembership(evt.PeerID, evt.OwnerUserID)
}
}
case "channel_self_boosts":
if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelBoosts != nil {

View file

@ -431,8 +431,8 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
return domain.ErrStarGiftTransferUnavailable
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3,
msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0,
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=$6,pinned_order=0,
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date, req.RecipientUnsaved); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
@ -446,6 +446,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
result.Saved.FromUserID = req.ActorUserID
result.Saved.Unsaved = req.RecipientUnsaved
if sourceSaved.Owner.Type == domain.PeerTypeUser {
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
return err
@ -587,10 +588,12 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
return domain.ErrStarGiftResaleUnavailable
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,from_user_id=$4,
msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=$8,pinned_order=0,can_transfer_at=0
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date,
req.To.Type == domain.PeerTypeUser && req.RecipientUnsaved); err != nil {
return err
}
result.Saved.Unsaved = req.To.Type == domain.PeerTypeUser && req.RecipientUnsaved
if req.To.Type == domain.PeerTypeUser {
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 135 {
t.Fatalf("migration status = %+v, want clean version 135", status)
if status.Dirty || status.Empty || status.Version != 145 {
t.Fatalf("migration status = %+v, want clean version 145", status)
}
}

View file

@ -278,7 +278,7 @@ last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err !=
}
saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID,
Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice,
PrepaidUpgradeHash: prepayHash, Message: req.Message}
PrepaidUpgradeHash: prepayHash, Message: req.Message, Unsaved: req.RecipientUnsaved}
return gift, saved, balance, nil
}

View file

@ -152,6 +152,7 @@ WHERE collectible_revision_id=$1 AND crafted
MsgID: ownerMessageID,
Date: req.Date,
NameHidden: req.HideName,
Unsaved: req.RecipientUnsaved,
LifecycleStatus: domain.StarGiftLifecycleActive,
Message: req.Message,
TransferStars: s.lifecycle.TransferStars,

View file

@ -244,12 +244,35 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
}); err != nil {
return err
}
if err := appendPrivacyPayload(ctx, db, userID, event); err != nil {
return err
}
if err := appendQuickReplyPayload(ctx, db, userID, event); err != nil {
return err
}
return nil
}
func appendPrivacyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
if event.Type != domain.UpdateEventPrivacy {
return nil
}
if event.Privacy.OwnerUserID != userID || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return domain.ErrPrivacyRuleInvalid
}
raw, err := json.Marshal(event.Privacy)
if err != nil {
return fmt.Errorf("encode privacy update payload: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_privacy_payloads (user_id, pts, payload)
VALUES ($1, $2, $3::jsonb)
`, userID, event.Pts, string(raw)); err != nil {
return fmt.Errorf("save privacy update payload: %w", err)
}
return nil
}
func appendQuickReplyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
switch event.Type {
case domain.UpdateEventQuickReplies,
@ -466,6 +489,9 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
@ -665,9 +691,77 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
type privacyEventCursor struct {
userID int64
pts int
}
// hydratePrivacyEvents fetches all immutable privacy payloads for one
// difference/outbox batch in one query. Ordinary event batches incur no extra
// query at all.
func (s *UpdateEventStore) hydratePrivacyEvents(ctx context.Context, events []domain.UpdateEvent) error {
indexes := make(map[privacyEventCursor]int)
userIDs := make([]int64, 0)
pts := make([]int32, 0)
for i := range events {
if events[i].Type != domain.UpdateEventPrivacy {
continue
}
key := privacyEventCursor{userID: events[i].UserID, pts: events[i].Pts}
indexes[key] = i
userIDs = append(userIDs, key.userID)
pts = append(pts, int32(key.pts))
}
if len(indexes) == 0 {
return nil
}
rows, err := s.db.Query(ctx, `
SELECT p.user_id, p.pts, p.payload::text
FROM unnest($1::bigint[], $2::int[]) AS requested(user_id, pts)
JOIN user_update_privacy_payloads p USING (user_id, pts)
`, userIDs, pts)
if err != nil {
return fmt.Errorf("list privacy update payloads: %w", err)
}
defer rows.Close()
found := 0
for rows.Next() {
var userID int64
var eventPts int
var raw string
if err := rows.Scan(&userID, &eventPts, &raw); err != nil {
return fmt.Errorf("scan privacy update payload: %w", err)
}
index, ok := indexes[privacyEventCursor{userID: userID, pts: eventPts}]
if !ok {
continue
}
var payload domain.PrivacyRules
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("decode privacy update payload: %w", err)
}
if payload.OwnerUserID != userID || payload.Key == "" || len(payload.Rules) == 0 {
return fmt.Errorf("invalid privacy update payload for user %d pts %d", userID, eventPts)
}
events[index].Privacy = payload
delete(indexes, privacyEventCursor{userID: userID, pts: eventPts})
found++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("list privacy update payloads rows: %w", err)
}
if found != len(userIDs) || len(indexes) != 0 {
return fmt.Errorf("privacy update payload missing")
}
return nil
}
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
return mergeEventUsers(
domain.User{