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 }