feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
486
internal/domain/moderation.go
Normal file
486
internal/domain/moderation.go
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ModerationTaxonomyVersion = 1
|
||||
MaxModerationReportItems = 100
|
||||
MaxModerationMediaHolds = 1000
|
||||
MaxModerationOptionBytes = 32
|
||||
MaxModerationCommentRunes = 512
|
||||
MaxModerationEvidenceBytes = 1 << 20
|
||||
MaxModerationTotalEvidenceBytes = 4 << 20
|
||||
MaxModerationMediaStorageKeyBytes = 512
|
||||
MaxModerationReportsPerHour = 20
|
||||
MaxModerationReportsPerDay = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrModerationReportInvalid = errors.New("moderation report invalid")
|
||||
ErrModerationReportNotFound = errors.New("moderation report not found")
|
||||
ErrModerationCaseInvalid = errors.New("moderation case invalid")
|
||||
ErrModerationCaseNotFound = errors.New("moderation case not found")
|
||||
ErrModerationCaseConflict = errors.New("moderation case conflict")
|
||||
ErrModerationActionInvalid = errors.New("moderation action invalid")
|
||||
ErrModerationActionConflict = errors.New("moderation action conflict")
|
||||
ErrModerationPermissionDenied = errors.New("moderation permission denied")
|
||||
ErrModerationRateLimited = errors.New("moderation rate limited")
|
||||
ErrModerationEvidenceNotFound = errors.New("moderation evidence not found")
|
||||
ErrModerationDecisionNotFound = errors.New("moderation decision not found")
|
||||
ErrModerationImpressionExpired = errors.New("moderation impression expired")
|
||||
ErrModerationAppealLinkInvalid = errors.New("moderation appeal link invalid")
|
||||
)
|
||||
|
||||
// ModerationReportSource identifies the client RPC and evidence admission path.
|
||||
// Operational telemetry and authentication delivery diagnostics deliberately do
|
||||
// not use this type or the moderation report tables.
|
||||
type ModerationReportSource string
|
||||
|
||||
const (
|
||||
ModerationSourceAccountPeer ModerationReportSource = "account_peer"
|
||||
ModerationSourceProfilePhoto ModerationReportSource = "profile_photo"
|
||||
ModerationSourceMessagesSpam ModerationReportSource = "messages_spam"
|
||||
ModerationSourceMessages ModerationReportSource = "messages"
|
||||
ModerationSourceEncryptedSpam ModerationReportSource = "encrypted_spam"
|
||||
ModerationSourceReaction ModerationReportSource = "reaction"
|
||||
ModerationSourceChannelSpam ModerationReportSource = "channel_spam"
|
||||
ModerationSourceStory ModerationReportSource = "story"
|
||||
ModerationSourceEphemeral ModerationReportSource = "ephemeral"
|
||||
ModerationSourceSponsored ModerationReportSource = "sponsored"
|
||||
ModerationSourceAntiSpamFalsePositive ModerationReportSource = "antispam_false_positive"
|
||||
)
|
||||
|
||||
func (s ModerationReportSource) Valid() bool {
|
||||
switch s {
|
||||
case ModerationSourceAccountPeer, ModerationSourceProfilePhoto,
|
||||
ModerationSourceMessagesSpam, ModerationSourceMessages,
|
||||
ModerationSourceEncryptedSpam, ModerationSourceReaction,
|
||||
ModerationSourceChannelSpam, ModerationSourceStory,
|
||||
ModerationSourceEphemeral, ModerationSourceSponsored,
|
||||
ModerationSourceAntiSpamFalsePositive:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationReason is the canonical domain taxonomy shared by ReportReason
|
||||
// constructors and the opaque multi-step report option flow.
|
||||
type ModerationReason string
|
||||
|
||||
const (
|
||||
ModerationReasonSpam ModerationReason = "spam"
|
||||
ModerationReasonViolence ModerationReason = "violence"
|
||||
ModerationReasonPornography ModerationReason = "pornography"
|
||||
ModerationReasonChildAbuse ModerationReason = "child_abuse"
|
||||
ModerationReasonOther ModerationReason = "other"
|
||||
ModerationReasonCopyright ModerationReason = "copyright"
|
||||
ModerationReasonGeoIrrelevant ModerationReason = "geo_irrelevant"
|
||||
ModerationReasonFake ModerationReason = "fake"
|
||||
ModerationReasonIllegalDrugs ModerationReason = "illegal_drugs"
|
||||
ModerationReasonPersonalDetails ModerationReason = "personal_details"
|
||||
)
|
||||
|
||||
func (r ModerationReason) Valid() bool {
|
||||
switch r {
|
||||
case ModerationReasonSpam, ModerationReasonViolence,
|
||||
ModerationReasonPornography, ModerationReasonChildAbuse,
|
||||
ModerationReasonOther, ModerationReasonCopyright,
|
||||
ModerationReasonGeoIrrelevant, ModerationReasonFake,
|
||||
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationReportItemKind string
|
||||
|
||||
const (
|
||||
ModerationItemPeer ModerationReportItemKind = "peer"
|
||||
ModerationItemMessage ModerationReportItemKind = "message"
|
||||
ModerationItemProfilePhoto ModerationReportItemKind = "profile_photo"
|
||||
ModerationItemReaction ModerationReportItemKind = "reaction"
|
||||
ModerationItemStory ModerationReportItemKind = "story"
|
||||
ModerationItemEncryptedChat ModerationReportItemKind = "encrypted_chat"
|
||||
ModerationItemEphemeral ModerationReportItemKind = "ephemeral"
|
||||
ModerationItemSponsored ModerationReportItemKind = "sponsored"
|
||||
ModerationItemAntiSpamDecision ModerationReportItemKind = "antispam_decision"
|
||||
)
|
||||
|
||||
func (k ModerationReportItemKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationItemPeer, ModerationItemMessage,
|
||||
ModerationItemProfilePhoto, ModerationItemReaction,
|
||||
ModerationItemStory, ModerationItemEncryptedChat,
|
||||
ModerationItemEphemeral, ModerationItemSponsored,
|
||||
ModerationItemAntiSpamDecision:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationMediaKind string
|
||||
|
||||
const (
|
||||
ModerationMediaPhoto ModerationMediaKind = "photo"
|
||||
ModerationMediaDocument ModerationMediaKind = "document"
|
||||
ModerationMediaBlob ModerationMediaKind = "blob"
|
||||
)
|
||||
|
||||
func (k ModerationMediaKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationMediaPhoto, ModerationMediaDocument, ModerationMediaBlob:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationReportItem is a stable reference plus a privacy-bounded evidence
|
||||
// snapshot. Evidence must be a versioned JSON object produced by the owning app
|
||||
// service; moderation never repairs malformed historical snapshots on read.
|
||||
type ModerationReportItem struct {
|
||||
Kind ModerationReportItemKind
|
||||
Peer Peer
|
||||
ItemID int64
|
||||
SecondaryID int64
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
}
|
||||
|
||||
type ModerationMediaHold struct {
|
||||
ItemIndex int
|
||||
Kind ModerationMediaKind
|
||||
StorageKey string
|
||||
}
|
||||
|
||||
// ModerationReport is immutable after acceptance. ID is assigned by the store;
|
||||
// Fingerprint is a deterministic SHA-256 of the immutable client intent and
|
||||
// evidence identity, excluding CreatedAt.
|
||||
type ModerationReport struct {
|
||||
ID int64
|
||||
ReporterUserID int64
|
||||
Source ModerationReportSource
|
||||
Target Peer
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CommentHash [sha256.Size]byte
|
||||
Fingerprint [sha256.Size]byte
|
||||
TaxonomyVersion int
|
||||
Items []ModerationReportItem
|
||||
MediaHolds []ModerationMediaHold
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationReportDraft struct {
|
||||
ReporterUserID int64
|
||||
Source ModerationReportSource
|
||||
Target Peer
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
TaxonomyVersion int
|
||||
Items []ModerationReportItem
|
||||
MediaHolds []ModerationMediaHold
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationMessageReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
MessageIDs []int
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationStoryReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
StoryIDs []int
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationProfilePhotoReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
PhotoID int64
|
||||
AccessHash int64
|
||||
FileReference []byte
|
||||
Reason ModerationReason
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationChannelSpamReportRequest struct {
|
||||
ReporterUserID int64
|
||||
ChannelID int64
|
||||
ParticipantUserID int64
|
||||
MessageIDs []int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationReactionReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
MessageID int
|
||||
ReactorUserID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewModerationReport canonicalizes item order and computes all content hashes.
|
||||
// Callers must pass snapshots, not mutable domain objects.
|
||||
func NewModerationReport(draft ModerationReportDraft) (ModerationReport, error) {
|
||||
originalItems := cloneModerationItems(draft.Items)
|
||||
report := ModerationReport{
|
||||
ReporterUserID: draft.ReporterUserID,
|
||||
Source: draft.Source,
|
||||
Target: draft.Target,
|
||||
Reason: draft.Reason,
|
||||
Option: draft.Option,
|
||||
Comment: draft.Comment,
|
||||
TaxonomyVersion: draft.TaxonomyVersion,
|
||||
Items: cloneModerationItems(originalItems),
|
||||
MediaHolds: append([]ModerationMediaHold(nil), draft.MediaHolds...),
|
||||
CreatedAt: draft.CreatedAt,
|
||||
}
|
||||
if report.TaxonomyVersion == 0 {
|
||||
report.TaxonomyVersion = ModerationTaxonomyVersion
|
||||
}
|
||||
report.CommentHash = sha256.Sum256([]byte(report.Comment))
|
||||
for i := range report.Items {
|
||||
evidence, err := CanonicalModerationEvidence(report.Items[i].Evidence)
|
||||
if err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
report.Items[i].Evidence = evidence
|
||||
report.Items[i].EvidenceHash = sha256.Sum256(report.Items[i].Evidence)
|
||||
}
|
||||
sort.Slice(report.Items, func(i, j int) bool {
|
||||
return moderationItemLess(report.Items[i], report.Items[j])
|
||||
})
|
||||
canonicalIndexes := make(map[moderationItemIdentity]int, len(report.Items))
|
||||
for i, item := range report.Items {
|
||||
canonicalIndexes[moderationItemIdentityOf(item)] = i
|
||||
}
|
||||
for i := range report.MediaHolds {
|
||||
oldIndex := report.MediaHolds[i].ItemIndex
|
||||
if oldIndex >= 0 && oldIndex < len(originalItems) {
|
||||
if canonicalIndex, ok := canonicalIndexes[moderationItemIdentityOf(originalItems[oldIndex])]; ok {
|
||||
report.MediaHolds[i].ItemIndex = canonicalIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(report.MediaHolds, func(i, j int) bool {
|
||||
a, b := report.MediaHolds[i], report.MediaHolds[j]
|
||||
if a.ItemIndex != b.ItemIndex {
|
||||
return a.ItemIndex < b.ItemIndex
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.StorageKey < b.StorageKey
|
||||
})
|
||||
fingerprint, err := moderationReportFingerprint(report)
|
||||
if err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
report.Fingerprint = fingerprint
|
||||
if err := report.Validate(); err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (r ModerationReport) Validate() error {
|
||||
if r.ID < 0 || r.ReporterUserID <= 0 || !r.Source.Valid() ||
|
||||
!moderationPeerValid(r.Target) || !r.Reason.Valid() ||
|
||||
r.Option == "" || len(r.Option) > MaxModerationOptionBytes ||
|
||||
!utf8.ValidString(r.Option) || !utf8.ValidString(r.Comment) ||
|
||||
utf8.RuneCountInString(r.Comment) > MaxModerationCommentRunes ||
|
||||
r.TaxonomyVersion <= 0 || r.TaxonomyVersion > 32767 ||
|
||||
len(r.Items) == 0 || len(r.Items) > MaxModerationReportItems ||
|
||||
len(r.MediaHolds) > MaxModerationMediaHolds || r.CreatedAt.IsZero() ||
|
||||
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
totalEvidence := 0
|
||||
seenItems := make(map[moderationItemIdentity]struct{}, len(r.Items))
|
||||
for i, item := range r.Items {
|
||||
if !item.Kind.Valid() || !moderationPeerValid(item.Peer) ||
|
||||
item.ItemID <= 0 || item.SecondaryID < 0 || item.AuthorUserID < 0 ||
|
||||
item.EvidenceSchemaVersion <= 0 || item.EvidenceSchemaVersion > 32767 ||
|
||||
len(item.Evidence) == 0 || len(item.Evidence) > MaxModerationEvidenceBytes ||
|
||||
!json.Valid(item.Evidence) ||
|
||||
item.EvidenceHash != sha256.Sum256(item.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
canonical, err := CanonicalModerationEvidence(item.Evidence)
|
||||
if err != nil || !bytes.Equal(canonical, item.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
if i > 0 && moderationItemLess(item, r.Items[i-1]) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
identity := moderationItemIdentityOf(item)
|
||||
if _, duplicate := seenItems[identity]; duplicate {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
seenItems[identity] = struct{}{}
|
||||
totalEvidence += len(item.Evidence)
|
||||
if totalEvidence > MaxModerationTotalEvidenceBytes {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
}
|
||||
seenHolds := make(map[ModerationMediaHold]struct{}, len(r.MediaHolds))
|
||||
for _, hold := range r.MediaHolds {
|
||||
if hold.ItemIndex < 0 || hold.ItemIndex >= len(r.Items) ||
|
||||
!hold.Kind.Valid() || hold.StorageKey == "" ||
|
||||
len(hold.StorageKey) > MaxModerationMediaStorageKeyBytes ||
|
||||
!utf8.ValidString(hold.StorageKey) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
if _, duplicate := seenHolds[hold]; duplicate {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
seenHolds[hold] = struct{}{}
|
||||
}
|
||||
fingerprint, err := moderationReportFingerprint(r)
|
||||
if err != nil || fingerprint != r.Fingerprint {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type moderationItemIdentity struct {
|
||||
Kind ModerationReportItemKind
|
||||
PeerType PeerType
|
||||
PeerID int64
|
||||
ItemID int64
|
||||
SecondaryID int64
|
||||
}
|
||||
|
||||
func moderationItemIdentityOf(item ModerationReportItem) moderationItemIdentity {
|
||||
return moderationItemIdentity{
|
||||
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||
}
|
||||
}
|
||||
|
||||
func moderationItemLess(a, b ModerationReportItem) bool {
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.Peer.Type != b.Peer.Type {
|
||||
return a.Peer.Type < b.Peer.Type
|
||||
}
|
||||
if a.Peer.ID != b.Peer.ID {
|
||||
return a.Peer.ID < b.Peer.ID
|
||||
}
|
||||
if a.ItemID != b.ItemID {
|
||||
return a.ItemID < b.ItemID
|
||||
}
|
||||
return a.SecondaryID < b.SecondaryID
|
||||
}
|
||||
|
||||
func moderationPeerValid(peer Peer) bool {
|
||||
return peer.ID > 0 && (peer.Type == PeerTypeUser || peer.Type == PeerTypeChannel)
|
||||
}
|
||||
|
||||
// CanonicalModerationEvidence normalizes a JSON object with Go's deterministic
|
||||
// map-key ordering. This keeps evidence hashes stable after PostgreSQL jsonb
|
||||
// normalizes whitespace and object key order.
|
||||
func CanonicalModerationEvidence(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
if _, ok := value.(map[string]any); !ok {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil || len(canonical) == 0 || len(canonical) > MaxModerationEvidenceBytes {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
type moderationFingerprintItem struct {
|
||||
Kind ModerationReportItemKind `json:"kind"`
|
||||
PeerType PeerType `json:"peer_type"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
SecondaryID int64 `json:"secondary_id"`
|
||||
AuthorUserID int64 `json:"author_user_id"`
|
||||
EvidenceSchemaVersion int `json:"evidence_schema_version"`
|
||||
EvidenceHash [sha256.Size]byte `json:"evidence_hash"`
|
||||
}
|
||||
|
||||
type moderationFingerprintPayload struct {
|
||||
Version int `json:"version"`
|
||||
ReporterUserID int64 `json:"reporter_user_id"`
|
||||
Source ModerationReportSource `json:"source"`
|
||||
TargetType PeerType `json:"target_type"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
Reason ModerationReason `json:"reason"`
|
||||
Option string `json:"option"`
|
||||
CommentHash [sha256.Size]byte `json:"comment_hash"`
|
||||
TaxonomyVersion int `json:"taxonomy_version"`
|
||||
Items []moderationFingerprintItem `json:"items"`
|
||||
}
|
||||
|
||||
func moderationReportFingerprint(report ModerationReport) ([sha256.Size]byte, error) {
|
||||
items := make([]moderationFingerprintItem, 0, len(report.Items))
|
||||
for _, item := range report.Items {
|
||||
items = append(items, moderationFingerprintItem{
|
||||
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||
AuthorUserID: item.AuthorUserID,
|
||||
EvidenceSchemaVersion: item.EvidenceSchemaVersion,
|
||||
EvidenceHash: item.EvidenceHash,
|
||||
})
|
||||
}
|
||||
raw, err := json.Marshal(moderationFingerprintPayload{
|
||||
Version: 1, ReporterUserID: report.ReporterUserID, Source: report.Source,
|
||||
TargetType: report.Target.Type, TargetID: report.Target.ID,
|
||||
Reason: report.Reason, Option: report.Option,
|
||||
CommentHash: report.CommentHash, TaxonomyVersion: report.TaxonomyVersion,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, ErrModerationReportInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
|
||||
func cloneModerationItems(items []ModerationReportItem) []ModerationReportItem {
|
||||
out := make([]ModerationReportItem, len(items))
|
||||
copy(out, items)
|
||||
for i := range out {
|
||||
out[i].Evidence = append(json.RawMessage(nil), items[i].Evidence...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CloneModerationReport(report ModerationReport) ModerationReport {
|
||||
report.Items = cloneModerationItems(report.Items)
|
||||
report.MediaHolds = append([]ModerationMediaHold(nil), report.MediaHolds...)
|
||||
return report
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue