Adds a narrower spam sanction alongside the existing account freeze: a restricted account keeps every existing membership and conversation, but cannot join new channels/groups (public join or invite link) and cannot start a new conversation with a non-contact. Reachable both as a standalone admin action and as a decision on a reported user's moderation case, with the same idempotent-supersession and appeal wiring freeze already has.
529 lines
16 KiB
Go
529 lines
16 KiB
Go
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"
|
|
ModerationActionRestrictAccount ModerationActionKind = "restrict_account"
|
|
ModerationActionUnrestrictAccount ModerationActionKind = "unrestrict_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,
|
|
ModerationActionRestrictAccount, ModerationActionUnrestrictAccount,
|
|
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"
|
|
ModerationSanctionAccountRestrict ModerationSanctionFamily = "account_restrict"
|
|
)
|
|
|
|
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
|
switch k {
|
|
case ModerationActionMarkScam, ModerationActionMarkFake,
|
|
ModerationActionClearPeerFlags:
|
|
return ModerationSanctionPeerFlags, true
|
|
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
|
return ModerationSanctionAccountFreeze, true
|
|
case ModerationActionRestrictAccount, ModerationActionUnrestrictAccount:
|
|
return ModerationSanctionAccountRestrict, 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
|
|
}
|