feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
114
internal/domain/auth_delivery_report.go
Normal file
114
internal/domain/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxAuthDeliveryMNCBytes = 8
|
||||
MaxAuthDeliveryClientTypeBytes = 32
|
||||
MaxAuthDeliveryIDBytes = 128
|
||||
MaxAuthDeliveryReportsPerHour = 10
|
||||
MaxAuthDeliveryReportsPerPhoneDay = 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthDeliveryReportInvalid = errors.New("auth delivery report invalid")
|
||||
ErrAuthDeliveryRateLimited = errors.New("auth delivery report rate limited")
|
||||
)
|
||||
|
||||
// AuthDeliveryReport is operational delivery telemetry, not an abuse report.
|
||||
// It deliberately stores only hashes of the phone and phone_code_hash and
|
||||
// never stores the authentication code.
|
||||
type AuthDeliveryReport struct {
|
||||
ID int64
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
ClientType string
|
||||
PhoneHash [sha256.Size]byte
|
||||
CodeHash [sha256.Size]byte
|
||||
IssuedUserID int64
|
||||
DeliveryID string
|
||||
Channel AuthCodeDeliveryKind
|
||||
MNC string
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthMissingCodeReportRequest struct {
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
ClientType string
|
||||
Phone string
|
||||
PhoneCodeHash string
|
||||
MNC string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewAuthDeliveryReport(authKeyID [8]byte, sessionID int64, clientType, phone, phoneCodeHash string, issuedUserID int64, deliveryID string, channel AuthCodeDeliveryKind, mnc string, createdAt time.Time) (AuthDeliveryReport, error) {
|
||||
report := AuthDeliveryReport{
|
||||
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
|
||||
PhoneHash: sha256.Sum256([]byte(phone)), CodeHash: sha256.Sum256([]byte(phoneCodeHash)),
|
||||
IssuedUserID: issuedUserID, DeliveryID: deliveryID,
|
||||
Channel: channel, MNC: mnc, CreatedAt: createdAt,
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
PhoneHash [sha256.Size]byte
|
||||
CodeHash [sha256.Size]byte
|
||||
DeliveryID string
|
||||
Channel AuthCodeDeliveryKind
|
||||
MNC string
|
||||
}{
|
||||
Version: 1, AuthKeyID: authKeyID, SessionID: sessionID,
|
||||
PhoneHash: report.PhoneHash, CodeHash: report.CodeHash,
|
||||
DeliveryID: deliveryID, Channel: channel, MNC: mnc,
|
||||
})
|
||||
if err != nil {
|
||||
return AuthDeliveryReport{}, ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
report.Fingerprint = sha256.Sum256(raw)
|
||||
if err := report.Validate(); err != nil {
|
||||
return AuthDeliveryReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (r AuthDeliveryReport) Validate() error {
|
||||
if r.ID < 0 || r.AuthKeyID == ([8]byte{}) || r.SessionID == 0 ||
|
||||
r.PhoneHash == ([sha256.Size]byte{}) || r.CodeHash == ([sha256.Size]byte{}) ||
|
||||
r.Fingerprint == ([sha256.Size]byte{}) || r.IssuedUserID < 0 ||
|
||||
len(r.ClientType) > MaxAuthDeliveryClientTypeBytes || !utf8.ValidString(r.ClientType) ||
|
||||
len(r.DeliveryID) > MaxAuthDeliveryIDBytes || !utf8.ValidString(r.DeliveryID) ||
|
||||
!validAuthDeliveryChannel(r.Channel) || !validMNC(r.MNC) || r.CreatedAt.IsZero() {
|
||||
return ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAuthDeliveryChannel(channel AuthCodeDeliveryKind) bool {
|
||||
switch channel {
|
||||
case AuthCodeDeliveryPhone, AuthCodeDeliverySMS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validMNC(mnc string) bool {
|
||||
if len(mnc) > MaxAuthDeliveryMNCBytes {
|
||||
return false
|
||||
}
|
||||
for _, r := range mnc {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -508,6 +508,21 @@ type ChannelMember struct {
|
|||
Guest bool
|
||||
}
|
||||
|
||||
// CanInviteUsers reports whether this active member may directly add users.
|
||||
// Keep this predicate aligned with both memory/postgres write boundaries so an
|
||||
// RPC cannot expose another user's privacy decision before authorizing the
|
||||
// actor.
|
||||
func (m ChannelMember) CanInviteUsers(channel Channel) bool {
|
||||
if m.Status != ChannelMemberActive {
|
||||
return false
|
||||
}
|
||||
if m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && (m.AdminRights.InviteUsers || m.AdminRights.ChangeInfo)) {
|
||||
return true
|
||||
}
|
||||
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !m.BannedRights.InviteUsers
|
||||
}
|
||||
|
||||
// CanManageDirectMessages reports whether this active parent-channel member may
|
||||
// see and address every subscriber topic in the linked direct-messages
|
||||
// monoforum. Telegram deliberately does not grant this capability to an
|
||||
|
|
@ -869,6 +884,22 @@ type ChannelMessageReactionsList struct {
|
|||
NextOffset string
|
||||
}
|
||||
|
||||
// ChannelMessageReactionLookupRequest is the bounded exact lookup used by
|
||||
// moderation evidence capture. It avoids paging an arbitrarily large reactor
|
||||
// list merely to prove that one named participant reacted.
|
||||
type ChannelMessageReactionLookupRequest struct {
|
||||
ViewerUserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
ReactorUserID int64
|
||||
}
|
||||
|
||||
type ChannelMessageReactionLookup struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Reactions []ChannelMessagePeerReaction
|
||||
}
|
||||
|
||||
// RecentMessageReaction is one account-level recently used message reaction.
|
||||
type RecentMessageReaction struct {
|
||||
UserID int64
|
||||
|
|
|
|||
131
internal/domain/client_telemetry.go
Normal file
131
internal/domain/client_telemetry.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxClientTelemetrySubjects = 100
|
||||
MaxClientTelemetryPayloadBytes = 64 << 10
|
||||
MaxClientTelemetryEventsPerHour = 1000
|
||||
MaxClientTelemetryEventsPerDay = 10000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientTelemetryInvalid = errors.New("client telemetry invalid")
|
||||
ErrClientTelemetryRateLimited = errors.New("client telemetry rate limited")
|
||||
)
|
||||
|
||||
type ClientTelemetryKind string
|
||||
|
||||
const (
|
||||
ClientTelemetryMessageDelivery ClientTelemetryKind = "message_delivery"
|
||||
ClientTelemetryReadMetrics ClientTelemetryKind = "read_metrics"
|
||||
ClientTelemetryMusicListen ClientTelemetryKind = "music_listen"
|
||||
)
|
||||
|
||||
func (k ClientTelemetryKind) Valid() bool {
|
||||
switch k {
|
||||
case ClientTelemetryMessageDelivery, ClientTelemetryReadMetrics,
|
||||
ClientTelemetryMusicListen:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ClientTelemetryEvent is operational product telemetry. It is deliberately
|
||||
// isolated from moderation reports/cases and has TTL-based retention.
|
||||
type ClientTelemetryEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind ClientTelemetryKind
|
||||
Peer Peer
|
||||
SubjectIDs []int64
|
||||
Payload json.RawMessage
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewClientTelemetryEvent(userID int64, kind ClientTelemetryKind, peer Peer, subjectIDs []int64, payload any, createdAt time.Time) (ClientTelemetryEvent, error) {
|
||||
canonicalIDs := append([]int64(nil), subjectIDs...)
|
||||
sort.Slice(canonicalIDs, func(i, j int) bool { return canonicalIDs[i] < canonicalIDs[j] })
|
||||
for i, id := range canonicalIDs {
|
||||
if id <= 0 || (i > 0 && canonicalIDs[i-1] == id) {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil || object == nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
raw, err = json.Marshal(object)
|
||||
if err != nil || len(raw) > MaxClientTelemetryPayloadBytes {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
event := ClientTelemetryEvent{
|
||||
UserID: userID, Kind: kind, Peer: peer,
|
||||
SubjectIDs: canonicalIDs, Payload: raw, CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
fingerprintInput, err := json.Marshal(struct {
|
||||
Version int
|
||||
UserID int64
|
||||
Kind ClientTelemetryKind
|
||||
Peer Peer
|
||||
SubjectIDs []int64
|
||||
Payload json.RawMessage
|
||||
Minute int64
|
||||
}{
|
||||
Version: 1, UserID: event.UserID, Kind: event.Kind, Peer: event.Peer,
|
||||
SubjectIDs: event.SubjectIDs, Payload: event.Payload,
|
||||
Minute: event.CreatedAt.Truncate(time.Minute).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
event.Fingerprint = sha256.Sum256(fingerprintInput)
|
||||
if err := event.Validate(); err != nil {
|
||||
return ClientTelemetryEvent{}, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e ClientTelemetryEvent) Validate() error {
|
||||
if e.ID < 0 || e.UserID <= 0 || !e.Kind.Valid() ||
|
||||
len(e.SubjectIDs) == 0 ||
|
||||
len(e.SubjectIDs) > MaxClientTelemetrySubjects ||
|
||||
len(e.Payload) == 0 || len(e.Payload) > MaxClientTelemetryPayloadBytes ||
|
||||
e.Fingerprint == ([sha256.Size]byte{}) || e.CreatedAt.IsZero() {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
if e.Peer.ID == 0 {
|
||||
if e.Peer.Type != "" || e.Kind != ClientTelemetryMusicListen {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
} else if !moderationPeerValid(e.Peer) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
for i, id := range e.SubjectIDs {
|
||||
if id <= 0 || (i > 0 && e.SubjectIDs[i-1] >= id) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &object); err != nil || object == nil {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(object)
|
||||
if err != nil || !bytes.Equal(canonical, e.Payload) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
65
internal/domain/client_telemetry_test.go
Normal file
65
internal/domain/client_telemetry_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientTelemetryEventCanonicalizesSubjectsAndMinuteIdempotency(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC().Truncate(time.Minute).Add(time.Second)
|
||||
peer := Peer{Type: PeerTypeUser, ID: 22}
|
||||
first, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{3, 1, 2},
|
||||
map[string]any{"push": true}, now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{2, 3, 1},
|
||||
struct {
|
||||
Push bool `json:"push"`
|
||||
}{Push: true},
|
||||
now.Add(30*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := first.SubjectIDs; len(got) != 3 ||
|
||||
got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||
t.Fatalf("canonical subjects = %v", got)
|
||||
}
|
||||
if first.Fingerprint != retry.Fingerprint {
|
||||
t.Fatal("same telemetry inside one minute must have one fingerprint")
|
||||
}
|
||||
nextMinute, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{1, 2, 3},
|
||||
map[string]any{"push": true}, now.Add(time.Minute),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nextMinute.Fingerprint == first.Fingerprint {
|
||||
t.Fatal("a new minute bucket must produce a new fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientTelemetryEventRejectsDuplicateSubjectsAndInvalidPeer(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
_, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryReadMetrics,
|
||||
Peer{Type: PeerTypeUser, ID: 22},
|
||||
[]int64{1, 1}, map[string]any{"metrics": []int{1}}, now,
|
||||
)
|
||||
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||
t.Fatalf("duplicate subjects err=%v", err)
|
||||
}
|
||||
_, err = NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, Peer{},
|
||||
[]int64{1}, map[string]any{"push": true}, now,
|
||||
)
|
||||
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||
t.Fatalf("missing message peer err=%v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
523
internal/domain/moderation_case.go
Normal file
523
internal/domain/moderation_case.go
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxModerationActorBytes = 128
|
||||
MaxModerationDecisionCommandBytes = 120
|
||||
MaxModerationDecisionTextRunes = 2000
|
||||
MaxModerationActionPayload = 64 << 10
|
||||
MaxModerationCasePage = 100
|
||||
MaxModerationCaseDetailEntries = 100
|
||||
MaxModerationActionsPerCase = 100
|
||||
MaxModerationAppealTextRunes = 4000
|
||||
MaxModerationActionAttempts = 20
|
||||
MaxModerationAppealLinksPerCase = 20
|
||||
MaxModerationAppealLinkLifetime = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type ModerationSeverity int16
|
||||
|
||||
const (
|
||||
ModerationSeverityLow ModerationSeverity = iota + 1
|
||||
ModerationSeverityMedium
|
||||
ModerationSeverityHigh
|
||||
ModerationSeverityCritical
|
||||
)
|
||||
|
||||
func (s ModerationSeverity) Valid() bool {
|
||||
return s >= ModerationSeverityLow && s <= ModerationSeverityCritical
|
||||
}
|
||||
|
||||
func ModerationSeverityForReason(reason ModerationReason) ModerationSeverity {
|
||||
switch reason {
|
||||
case ModerationReasonChildAbuse:
|
||||
return ModerationSeverityCritical
|
||||
case ModerationReasonViolence, ModerationReasonPornography,
|
||||
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||
return ModerationSeverityHigh
|
||||
case ModerationReasonFake, ModerationReasonCopyright:
|
||||
return ModerationSeverityMedium
|
||||
default:
|
||||
return ModerationSeverityLow
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationCaseStatus string
|
||||
|
||||
const (
|
||||
ModerationCaseOpen ModerationCaseStatus = "open"
|
||||
ModerationCaseInReview ModerationCaseStatus = "in_review"
|
||||
ModerationCaseActionPending ModerationCaseStatus = "action_pending"
|
||||
ModerationCaseActionFailed ModerationCaseStatus = "action_failed"
|
||||
ModerationCaseResolved ModerationCaseStatus = "resolved"
|
||||
ModerationCaseDismissed ModerationCaseStatus = "dismissed"
|
||||
ModerationCaseAppealReview ModerationCaseStatus = "appeal_review"
|
||||
)
|
||||
|
||||
func (s ModerationCaseStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationCaseOpen, ModerationCaseInReview,
|
||||
ModerationCaseActionPending, ModerationCaseResolved,
|
||||
ModerationCaseActionFailed, ModerationCaseDismissed,
|
||||
ModerationCaseAppealReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s ModerationCaseStatus) Active() bool {
|
||||
switch s {
|
||||
case ModerationCaseOpen, ModerationCaseInReview,
|
||||
ModerationCaseActionPending, ModerationCaseActionFailed,
|
||||
ModerationCaseAppealReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationDecisionKind string
|
||||
|
||||
const (
|
||||
ModerationDecisionNoViolation ModerationDecisionKind = "no_violation"
|
||||
ModerationDecisionViolation ModerationDecisionKind = "violation"
|
||||
ModerationDecisionAppealGrant ModerationDecisionKind = "appeal_granted"
|
||||
ModerationDecisionAppealDeny ModerationDecisionKind = "appeal_denied"
|
||||
)
|
||||
|
||||
func (k ModerationDecisionKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationDecisionNoViolation, ModerationDecisionViolation,
|
||||
ModerationDecisionAppealGrant, ModerationDecisionAppealDeny:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationActionKind string
|
||||
|
||||
const (
|
||||
ModerationActionMarkScam ModerationActionKind = "mark_scam"
|
||||
ModerationActionMarkFake ModerationActionKind = "mark_fake"
|
||||
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
|
||||
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
|
||||
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
|
||||
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
|
||||
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
|
||||
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
|
||||
)
|
||||
|
||||
func (k ModerationActionKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
|
||||
ModerationActionUnfreezeAccount,
|
||||
ModerationActionDeletePrivateMessage,
|
||||
ModerationActionDeleteChannelMessage,
|
||||
ModerationActionDeleteAccount:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationActionStatus string
|
||||
|
||||
const (
|
||||
ModerationActionPending ModerationActionStatus = "pending"
|
||||
ModerationActionProcessing ModerationActionStatus = "processing"
|
||||
ModerationActionSucceeded ModerationActionStatus = "succeeded"
|
||||
ModerationActionSuperseded ModerationActionStatus = "superseded"
|
||||
ModerationActionRetry ModerationActionStatus = "retry"
|
||||
ModerationActionFailed ModerationActionStatus = "failed"
|
||||
)
|
||||
|
||||
func (s ModerationActionStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationActionPending, ModerationActionProcessing,
|
||||
ModerationActionSucceeded, ModerationActionSuperseded,
|
||||
ModerationActionRetry,
|
||||
ModerationActionFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationSanctionFamily groups reversible actions that mutate the same
|
||||
// target-scoped state. Only the latest desired action in a family may execute;
|
||||
// older queued work is retained as superseded audit history.
|
||||
type ModerationSanctionFamily string
|
||||
|
||||
const (
|
||||
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
|
||||
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
|
||||
)
|
||||
|
||||
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
||||
switch k {
|
||||
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||
ModerationActionClearPeerFlags:
|
||||
return ModerationSanctionPeerFlags, true
|
||||
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
||||
return ModerationSanctionAccountFreeze, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationAppealStatus string
|
||||
|
||||
const (
|
||||
ModerationAppealPending ModerationAppealStatus = "pending"
|
||||
ModerationAppealGranted ModerationAppealStatus = "granted"
|
||||
ModerationAppealRejected ModerationAppealStatus = "rejected"
|
||||
)
|
||||
|
||||
func (s ModerationAppealStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationAppealPending, ModerationAppealGranted, ModerationAppealRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationCase struct {
|
||||
ID int64
|
||||
Target Peer
|
||||
Status ModerationCaseStatus
|
||||
Severity ModerationSeverity
|
||||
AssignedTo string
|
||||
Version int64
|
||||
ReportCount int
|
||||
DistinctReporterCount int
|
||||
FirstReportAt time.Time
|
||||
LastReportAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (c ModerationCase) Validate() error {
|
||||
if c.ID <= 0 || !moderationPeerValid(c.Target) || !c.Status.Valid() ||
|
||||
!c.Severity.Valid() || c.Version <= 0 || c.ReportCount <= 0 ||
|
||||
c.DistinctReporterCount <= 0 ||
|
||||
c.DistinctReporterCount > c.ReportCount ||
|
||||
len(c.AssignedTo) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(c.AssignedTo) || c.FirstReportAt.IsZero() ||
|
||||
c.LastReportAt.Before(c.FirstReportAt) || c.CreatedAt.IsZero() ||
|
||||
c.UpdatedAt.Before(c.CreatedAt) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if (c.Status == ModerationCaseInReview ||
|
||||
c.Status == ModerationCaseActionPending ||
|
||||
c.Status == ModerationCaseActionFailed) && c.AssignedTo == "" {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationCaseFilter struct {
|
||||
Statuses []ModerationCaseStatus
|
||||
AssignedTo string
|
||||
Target Peer
|
||||
BeforeUpdate time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (f ModerationCaseFilter) Validate() error {
|
||||
if f.Limit <= 0 || f.Limit > MaxModerationCasePage ||
|
||||
len(f.AssignedTo) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(f.AssignedTo) || f.BeforeID < 0 {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if f.Target.ID != 0 && !moderationPeerValid(f.Target) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if f.Target.ID == 0 && f.Target.Type != "" {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
for _, status := range f.Statuses {
|
||||
if !status.Valid() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationCaseDetail struct {
|
||||
Case ModerationCase
|
||||
ReportIDs []int64
|
||||
Decisions []ModerationDecision
|
||||
Actions []ModerationAction
|
||||
Appeals []ModerationAppeal
|
||||
}
|
||||
|
||||
type ModerationDecision struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
Kind ModerationDecisionKind
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationActionDraft struct {
|
||||
Kind ModerationActionKind
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type ModerationDecisionRequest struct {
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
ExpectedVersion int64
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Kind ModerationDecisionKind
|
||||
Actions []ModerationActionDraft
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewModerationDecisionRequest(request ModerationDecisionRequest) (ModerationDecisionRequest, error) {
|
||||
out := request
|
||||
out.Actions = append([]ModerationActionDraft(nil), request.Actions...)
|
||||
for i := range out.Actions {
|
||||
canonical, err := CanonicalModerationActionPayload(out.Actions[i].Payload)
|
||||
if err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
out.Actions[i].Payload = canonical
|
||||
}
|
||||
fingerprint, err := moderationDecisionFingerprint(out)
|
||||
if err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
out.Fingerprint = fingerprint
|
||||
if err := out.Validate(); err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r ModerationDecisionRequest) Validate() error {
|
||||
if r.CaseID <= 0 || r.ExpectedVersion <= 0 || !r.Kind.Valid() ||
|
||||
r.Actor == "" || len(r.Actor) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(r.Actor) || r.CommandID == "" ||
|
||||
len(r.CommandID) > MaxModerationDecisionCommandBytes ||
|
||||
!utf8.ValidString(r.CommandID) ||
|
||||
r.Reason == "" || !utf8.ValidString(r.Reason) ||
|
||||
utf8.RuneCountInString(r.Reason) > MaxModerationDecisionTextRunes ||
|
||||
len(r.Actions) > MaxModerationActionsPerCase ||
|
||||
r.Fingerprint == ([sha256.Size]byte{}) || r.CreatedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionNoViolation && len(r.Actions) != 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionViolation && len(r.Actions) == 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if (r.Kind == ModerationDecisionAppealGrant ||
|
||||
r.Kind == ModerationDecisionAppealDeny) != (r.AppealID > 0) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionAppealDeny && len(r.Actions) != 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if (r.Kind == ModerationDecisionNoViolation ||
|
||||
r.Kind == ModerationDecisionViolation) && r.AppealID != 0 {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
for i := range r.Actions {
|
||||
canonical, err := CanonicalModerationActionPayload(r.Actions[i].Payload)
|
||||
if !r.Actions[i].Kind.Valid() || err != nil ||
|
||||
!bytes.Equal(canonical, r.Actions[i].Payload) {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
fingerprint, err := moderationDecisionFingerprint(r)
|
||||
if err != nil || fingerprint != r.Fingerprint {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationAction struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
DecisionID int64
|
||||
Kind ModerationActionKind
|
||||
Payload json.RawMessage
|
||||
Status ModerationActionStatus
|
||||
Attempts int
|
||||
AvailableAt time.Time
|
||||
LeaseUntil time.Time
|
||||
LastError string
|
||||
CommandID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (a ModerationAction) Validate() error {
|
||||
canonical, err := CanonicalModerationActionPayload(a.Payload)
|
||||
if a.ID <= 0 || a.CaseID <= 0 || a.DecisionID <= 0 ||
|
||||
!a.Kind.Valid() || !a.Status.Valid() || a.Attempts < 0 ||
|
||||
a.Attempts > MaxModerationActionAttempts || a.AvailableAt.IsZero() ||
|
||||
a.CommandID == "" || len(a.CommandID) > 160 ||
|
||||
!utf8.ValidString(a.CommandID) || a.CreatedAt.IsZero() ||
|
||||
a.UpdatedAt.Before(a.CreatedAt) || err != nil ||
|
||||
!bytes.Equal(canonical, a.Payload) {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationAppeal struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
Text string
|
||||
TextHash [sha256.Size]byte
|
||||
Fingerprint [sha256.Size]byte
|
||||
Status ModerationAppealStatus
|
||||
PreviousCaseStatus ModerationCaseStatus
|
||||
Reviewer string
|
||||
ReviewReason string
|
||||
CreatedAt time.Time
|
||||
ReviewedAt time.Time
|
||||
}
|
||||
|
||||
// ModerationAppealLink is a hash-only bearer capability issued for the user
|
||||
// targeted by a moderation case. The raw token is never persisted.
|
||||
type ModerationAppealLink struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
TokenHash [sha256.Size]byte
|
||||
ExpiresAt time.Time
|
||||
AppealID int64
|
||||
CreatedAt time.Time
|
||||
ConsumedAt time.Time
|
||||
}
|
||||
|
||||
func (l ModerationAppealLink) Validate() error {
|
||||
if l.ID < 0 || l.CaseID <= 0 || l.AppellantUserID <= 0 ||
|
||||
l.TokenHash == ([sha256.Size]byte{}) || l.CreatedAt.IsZero() ||
|
||||
!l.ExpiresAt.After(l.CreatedAt) ||
|
||||
l.ExpiresAt.Sub(l.CreatedAt) > MaxModerationAppealLinkLifetime ||
|
||||
l.AppealID < 0 {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
if l.AppealID == 0 {
|
||||
if !l.ConsumedAt.IsZero() {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
} else if l.ConsumedAt.IsZero() {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewModerationAppeal(caseID, appellantUserID int64, previousStatus ModerationCaseStatus, text string, createdAt time.Time) (ModerationAppeal, error) {
|
||||
appeal := ModerationAppeal{
|
||||
CaseID: caseID, AppellantUserID: appellantUserID, Text: text,
|
||||
TextHash: sha256.Sum256([]byte(text)), Status: ModerationAppealPending,
|
||||
PreviousCaseStatus: previousStatus, CreatedAt: createdAt,
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
PreviousStatus ModerationCaseStatus
|
||||
TextHash [sha256.Size]byte
|
||||
}{1, caseID, appellantUserID, previousStatus, appeal.TextHash})
|
||||
if err != nil {
|
||||
return ModerationAppeal{}, ErrModerationCaseInvalid
|
||||
}
|
||||
appeal.Fingerprint = sha256.Sum256(raw)
|
||||
if err := appeal.Validate(); err != nil {
|
||||
return ModerationAppeal{}, err
|
||||
}
|
||||
return appeal, nil
|
||||
}
|
||||
|
||||
func (a ModerationAppeal) Validate() error {
|
||||
if a.ID < 0 || a.CaseID <= 0 || a.AppellantUserID <= 0 ||
|
||||
a.Text == "" || !utf8.ValidString(a.Text) ||
|
||||
utf8.RuneCountInString(a.Text) > MaxModerationAppealTextRunes ||
|
||||
a.TextHash != sha256.Sum256([]byte(a.Text)) ||
|
||||
a.Fingerprint == ([sha256.Size]byte{}) || !a.Status.Valid() ||
|
||||
(a.PreviousCaseStatus != ModerationCaseResolved &&
|
||||
a.PreviousCaseStatus != ModerationCaseDismissed) ||
|
||||
len(a.Reviewer) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(a.Reviewer) || !utf8.ValidString(a.ReviewReason) ||
|
||||
utf8.RuneCountInString(a.ReviewReason) > MaxModerationDecisionTextRunes ||
|
||||
a.CreatedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if a.Status == ModerationAppealPending {
|
||||
if a.Reviewer != "" || a.ReviewReason != "" || !a.ReviewedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
} else if a.Reviewer == "" || a.ReviewReason == "" || a.ReviewedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CanonicalModerationActionPayload(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var value any
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage(`{}`)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
if _, ok := value.(map[string]any); !ok {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil || len(canonical) > MaxModerationActionPayload {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func moderationDecisionFingerprint(request ModerationDecisionRequest) ([sha256.Size]byte, error) {
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
ExpectedVersion int64
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Kind ModerationDecisionKind
|
||||
Actions []ModerationActionDraft
|
||||
}{
|
||||
Version: 1, CaseID: request.CaseID,
|
||||
AppealID: request.AppealID,
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: request.Kind, Actions: request.Actions,
|
||||
})
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, ErrModerationCaseInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
156
internal/domain/moderation_registry.go
Normal file
156
internal/domain/moderation_registry.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxSponsoredImpressionLifetime = 30 * 24 * time.Hour
|
||||
|
||||
// SponsoredMessageImpression is the server-issued fact required before a
|
||||
// random_id may enter the human moderation pipeline.
|
||||
type SponsoredMessageImpression struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
RandomIDHash [sha256.Size]byte
|
||||
Target Peer
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
ReportID int64
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewSponsoredMessageImpression(userID int64, randomID []byte, target Peer, authorUserID int64, evidence json.RawMessage, createdAt, expiresAt time.Time) (SponsoredMessageImpression, error) {
|
||||
canonical, err := CanonicalModerationEvidence(evidence)
|
||||
if err != nil {
|
||||
return SponsoredMessageImpression{}, ErrModerationReportInvalid
|
||||
}
|
||||
impression := SponsoredMessageImpression{
|
||||
UserID: userID, RandomIDHash: sha256.Sum256(randomID),
|
||||
Target: target, AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: canonical,
|
||||
EvidenceHash: sha256.Sum256(canonical),
|
||||
CreatedAt: createdAt.UTC(), ExpiresAt: expiresAt.UTC(),
|
||||
}
|
||||
if err := impression.Validate(); err != nil {
|
||||
return SponsoredMessageImpression{}, err
|
||||
}
|
||||
return impression, nil
|
||||
}
|
||||
|
||||
func (i SponsoredMessageImpression) Validate() error {
|
||||
canonical, err := CanonicalModerationEvidence(i.Evidence)
|
||||
if i.ID < 0 || i.UserID <= 0 ||
|
||||
i.RandomIDHash == ([sha256.Size]byte{}) ||
|
||||
!moderationPeerValid(i.Target) || i.AuthorUserID < 0 ||
|
||||
i.EvidenceSchemaVersion <= 0 ||
|
||||
i.EvidenceHash != sha256.Sum256(i.Evidence) ||
|
||||
err != nil || !bytes.Equal(canonical, i.Evidence) ||
|
||||
i.ReportID < 0 || i.CreatedAt.IsZero() ||
|
||||
!i.ExpiresAt.After(i.CreatedAt) ||
|
||||
i.ExpiresAt.Sub(i.CreatedAt) > MaxSponsoredImpressionLifetime {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateSponsoredModerationReport(impression SponsoredMessageImpression, report ModerationReport) error {
|
||||
if err := impression.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report.Validate(); err != nil ||
|
||||
report.ReporterUserID != impression.UserID ||
|
||||
report.Source != ModerationSourceSponsored ||
|
||||
report.Target != impression.Target ||
|
||||
report.CreatedAt.Before(impression.CreatedAt) ||
|
||||
!report.CreatedAt.Before(impression.ExpiresAt) ||
|
||||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
item := report.Items[0]
|
||||
if item.Kind != ModerationItemSponsored ||
|
||||
item.Peer != impression.Target ||
|
||||
item.ItemID != impression.ID || item.SecondaryID != 0 ||
|
||||
item.AuthorUserID != impression.AuthorUserID ||
|
||||
item.EvidenceSchemaVersion != impression.EvidenceSchemaVersion ||
|
||||
item.EvidenceHash != impression.EvidenceHash ||
|
||||
!bytes.Equal(item.Evidence, impression.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelAntiSpamDecision is immutable evidence that native anti-spam
|
||||
// actually removed the referenced message. A false-positive report without
|
||||
// this fact must fail closed.
|
||||
type ChannelAntiSpamDecision struct {
|
||||
ID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
ReportID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewChannelAntiSpamDecision(channelID int64, messageID int, authorUserID int64, evidence json.RawMessage, createdAt time.Time) (ChannelAntiSpamDecision, error) {
|
||||
canonical, err := CanonicalModerationEvidence(evidence)
|
||||
if err != nil {
|
||||
return ChannelAntiSpamDecision{}, ErrModerationReportInvalid
|
||||
}
|
||||
decision := ChannelAntiSpamDecision{
|
||||
ChannelID: channelID, MessageID: messageID,
|
||||
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: canonical, EvidenceHash: sha256.Sum256(canonical),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
if err := decision.Validate(); err != nil {
|
||||
return ChannelAntiSpamDecision{}, err
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (d ChannelAntiSpamDecision) Validate() error {
|
||||
canonical, err := CanonicalModerationEvidence(d.Evidence)
|
||||
if d.ID < 0 || d.ChannelID <= 0 || d.MessageID <= 0 ||
|
||||
d.MessageID > MaxMessageBoxID || d.AuthorUserID <= 0 ||
|
||||
d.EvidenceSchemaVersion <= 0 ||
|
||||
d.EvidenceHash != sha256.Sum256(d.Evidence) ||
|
||||
err != nil || !bytes.Equal(canonical, d.Evidence) ||
|
||||
d.ReportID < 0 || d.CreatedAt.IsZero() {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAntiSpamFalsePositiveReport(decision ChannelAntiSpamDecision, report ModerationReport) error {
|
||||
if err := decision.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
target := Peer{Type: PeerTypeChannel, ID: decision.ChannelID}
|
||||
if err := report.Validate(); err != nil ||
|
||||
report.Source != ModerationSourceAntiSpamFalsePositive ||
|
||||
report.Target != target ||
|
||||
report.CreatedAt.Before(decision.CreatedAt) ||
|
||||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
item := report.Items[0]
|
||||
if item.Kind != ModerationItemAntiSpamDecision ||
|
||||
item.Peer != target || item.ItemID != decision.ID ||
|
||||
item.SecondaryID != int64(decision.MessageID) ||
|
||||
item.AuthorUserID != decision.AuthorUserID ||
|
||||
item.EvidenceSchemaVersion != decision.EvidenceSchemaVersion ||
|
||||
item.EvidenceHash != decision.EvidenceHash ||
|
||||
!bytes.Equal(item.Evidence, decision.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
internal/domain/moderation_test.go
Normal file
87
internal/domain/moderation_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewModerationReportCanonicalizesEvidenceItemsAndHolds(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
draft := ModerationReportDraft{
|
||||
ReporterUserID: 101,
|
||||
Source: ModerationSourceMessages,
|
||||
Target: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
Reason: ModerationReasonSpam,
|
||||
Option: "v1/spam",
|
||||
Comment: "review",
|
||||
CreatedAt: now,
|
||||
Items: []ModerationReportItem{
|
||||
{
|
||||
Kind: ModerationItemStory, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
ItemID: 20, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{ "z": 1, "a": {"two": 2, "one": 1} }`),
|
||||
},
|
||||
{
|
||||
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
ItemID: 10, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"spam"}`),
|
||||
},
|
||||
},
|
||||
MediaHolds: []ModerationMediaHold{{
|
||||
ItemIndex: 0, Kind: ModerationMediaPhoto, StorageKey: "photo/20",
|
||||
}},
|
||||
}
|
||||
report, err := NewModerationReport(draft)
|
||||
if err != nil {
|
||||
t.Fatalf("NewModerationReport: %v", err)
|
||||
}
|
||||
if report.Items[0].Kind != ModerationItemMessage || report.Items[1].Kind != ModerationItemStory {
|
||||
t.Fatalf("items not canonicalized: %+v", report.Items)
|
||||
}
|
||||
if report.MediaHolds[0].ItemIndex != 1 {
|
||||
t.Fatalf("media hold item index = %d, want 1 after canonical sort", report.MediaHolds[0].ItemIndex)
|
||||
}
|
||||
if got, want := report.Items[1].Evidence, []byte(`{"a":{"one":1,"two":2},"z":1}`); !bytes.Equal(got, want) {
|
||||
t.Fatalf("canonical evidence = %s, want %s", got, want)
|
||||
}
|
||||
if err := report.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
|
||||
retry := draft
|
||||
retry.CreatedAt = now.Add(time.Hour)
|
||||
retryReport, err := NewModerationReport(retry)
|
||||
if err != nil {
|
||||
t.Fatalf("retry NewModerationReport: %v", err)
|
||||
}
|
||||
if retryReport.Fingerprint != report.Fingerprint {
|
||||
t.Fatalf("retry fingerprint changed with CreatedAt")
|
||||
}
|
||||
retry.Items = append([]ModerationReportItem(nil), retry.Items...)
|
||||
retry.Items[0].Evidence = []byte(`{"z":2}`)
|
||||
changed, err := NewModerationReport(retry)
|
||||
if err != nil {
|
||||
t.Fatalf("changed NewModerationReport: %v", err)
|
||||
}
|
||||
if changed.Fingerprint == report.Fingerprint {
|
||||
t.Fatalf("evidence change did not change fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationReportRejectsDuplicateItemIdentity(t *testing.T) {
|
||||
item := ModerationReportItem{
|
||||
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeUser, ID: 2},
|
||||
ItemID: 7, AuthorUserID: 2, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"bad"}`),
|
||||
}
|
||||
_, err := NewModerationReport(ModerationReportDraft{
|
||||
ReporterUserID: 1, Source: ModerationSourceMessages,
|
||||
Target: Peer{Type: PeerTypeUser, ID: 2},
|
||||
Reason: ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []ModerationReportItem{item, item}, CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != ErrModerationReportInvalid {
|
||||
t.Fatalf("error = %v, want ErrModerationReportInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,10 @@ func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
|
|||
switch key {
|
||||
case PrivacyKeyPhoneNumber:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||
case PrivacyKeyNoPaidMessages:
|
||||
// This key is an allow-list of peers exempt from paid private
|
||||
// messages, not the base visibility of a profile field.
|
||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||
case PrivacyKeyBirthday:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ type AdminStarGiftGrant struct {
|
|||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
RecipientUnsaved bool
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
|
|
@ -382,6 +383,7 @@ type StarGiftPurchaseRequest struct {
|
|||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
|
@ -555,15 +557,16 @@ type StarGiftValueInfo struct {
|
|||
}
|
||||
|
||||
type StarGiftTransferRequest struct {
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
To Peer
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
To Peer
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftTransferResult struct {
|
||||
|
|
@ -582,15 +585,16 @@ type StarGiftListingRequest struct {
|
|||
}
|
||||
|
||||
type StarGiftResalePurchaseRequest struct {
|
||||
BuyerUserID int64
|
||||
Slug string
|
||||
To Peer
|
||||
Amount StarGiftAmount
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
BuyerUserID int64
|
||||
Slug string
|
||||
To Peer
|
||||
Amount StarGiftAmount
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftOfferRequest struct {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ const (
|
|||
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
|
||||
// It consumes account pts even though updateUserEmojiStatus has no pts.
|
||||
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
// UpdateEventPrivacy carries the immutable account privacy key/rule
|
||||
// snapshot committed at this pts. updatePrivacy has no wire pts.
|
||||
UpdateEventPrivacy UpdateEventType = "privacy"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
|
||||
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
|
||||
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
|
||||
|
|
@ -88,6 +91,7 @@ type UpdateEvent struct {
|
|||
Bool bool
|
||||
Phone string
|
||||
EmojiStatus UserEmojiStatus
|
||||
Privacy PrivacyRules
|
||||
Settings PeerSettings
|
||||
MessageIDs []int
|
||||
MaxID int
|
||||
|
|
@ -140,6 +144,7 @@ func (e UpdateEvent) LacksWirePts() bool {
|
|||
UpdateEventPeerStoryBlocked,
|
||||
UpdateEventUserPhone,
|
||||
UpdateEventUserEmojiStatus,
|
||||
UpdateEventPrivacy,
|
||||
UpdateEventDialogFilter,
|
||||
UpdateEventDialogFilterOrder,
|
||||
UpdateEventDialogFilters,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,26 @@ type UserStatus struct {
|
|||
WasOnline int
|
||||
}
|
||||
|
||||
// ApproximateUserStatus returns Telegram's coarse privacy-preserving last-seen
|
||||
// buckets. Exact online/offline timestamps must never be reattached after this
|
||||
// projection.
|
||||
func ApproximateUserStatus(lastSeenAt, now int) UserStatus {
|
||||
if lastSeenAt <= 0 || now <= 0 || lastSeenAt >= now {
|
||||
return UserStatus{Kind: UserStatusRecently}
|
||||
}
|
||||
age := now - lastSeenAt
|
||||
switch {
|
||||
case age <= 3*24*60*60:
|
||||
return UserStatus{Kind: UserStatusRecently}
|
||||
case age <= 7*24*60*60:
|
||||
return UserStatus{Kind: UserStatusLastWeek}
|
||||
case age <= 30*24*60*60:
|
||||
return UserStatus{Kind: UserStatusLastMonth}
|
||||
default:
|
||||
return UserStatus{Kind: UserStatusEmpty}
|
||||
}
|
||||
}
|
||||
|
||||
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。
|
||||
type Birthday struct {
|
||||
Day int
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue