owpengram-server/internal/domain/moderation.go
2026-07-24 11:56:59 +08:00

486 lines
17 KiB
Go

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
}