owpengram-server/internal/store/memory/moderation.go
Astra f33e25af8d admin: add account spam restriction (join/message gate)
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.
2026-09-16 14:03:15 +01:00

1016 lines
35 KiB
Go

package memory
import (
"context"
"fmt"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// ModerationReportStore is the deterministic in-memory implementation used by
// RPC and app tests. Returned reports are deep copies so accepted evidence
// cannot be mutated through a caller-owned byte slice.
type ModerationReportStore struct {
mu sync.RWMutex
nextID int64
byID map[int64]domain.ModerationReport
byFingerprint map[[32]byte]int64
nextCaseID int64
cases map[int64]domain.ModerationCase
activeCases map[domain.Peer]int64
caseReports map[int64][]int64
nextDecisionID int64
decisions map[int64][]domain.ModerationDecision
decisionCommands map[string]domain.ModerationDecision
nextActionID int64
actions map[int64]domain.ModerationAction
nextAppealID int64
appeals map[int64]domain.ModerationAppeal
appealFingerprints map[[32]byte]int64
nextAppealLinkID int64
appealLinks map[[32]byte]domain.ModerationAppealLink
nextSponsoredID int64
sponsored map[memorySponsoredKey]domain.SponsoredMessageImpression
nextAntiSpamID int64
antiSpam map[memoryAntiSpamKey]domain.ChannelAntiSpamDecision
}
type memorySponsoredKey struct {
UserID int64
RandomIDHash [32]byte
}
type memoryAntiSpamKey struct {
ChannelID int64
MessageID int
}
func NewModerationReportStore() *ModerationReportStore {
return &ModerationReportStore{
nextID: 1,
byID: make(map[int64]domain.ModerationReport),
byFingerprint: make(map[[32]byte]int64),
nextCaseID: 1, cases: make(map[int64]domain.ModerationCase),
activeCases: make(map[domain.Peer]int64),
caseReports: make(map[int64][]int64),
nextDecisionID: 1, decisions: make(map[int64][]domain.ModerationDecision),
decisionCommands: make(map[string]domain.ModerationDecision),
nextActionID: 1, actions: make(map[int64]domain.ModerationAction),
nextAppealID: 1, appeals: make(map[int64]domain.ModerationAppeal),
appealFingerprints: make(map[[32]byte]int64),
nextAppealLinkID: 1,
appealLinks: make(map[[32]byte]domain.ModerationAppealLink),
nextSponsoredID: 1,
sponsored: make(map[memorySponsoredKey]domain.SponsoredMessageImpression),
nextAntiSpamID: 1,
antiSpam: make(map[memoryAntiSpamKey]domain.ChannelAntiSpamDecision),
}
}
func (s *ModerationReportStore) CreateModerationReport(_ context.Context, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.createModerationReportLocked(report)
}
func (s *ModerationReportStore) createModerationReportLocked(report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if id, exists := s.byFingerprint[report.Fingerprint]; exists {
return domain.CloneModerationReport(s.byID[id]), false, nil
}
var hourly, daily int
hourAgo := report.CreatedAt.Add(-time.Hour)
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
for _, existing := range s.byID {
if existing.ReporterUserID != report.ReporterUserID || existing.CreatedAt.After(report.CreatedAt) {
continue
}
if !existing.CreatedAt.Before(dayAgo) {
daily++
}
if !existing.CreatedAt.Before(hourAgo) {
hourly++
}
}
if hourly >= domain.MaxModerationReportsPerHour || daily >= domain.MaxModerationReportsPerDay {
return domain.ModerationReport{}, false, domain.ErrModerationRateLimited
}
report.ID = s.nextID
s.nextID++
stored := domain.CloneModerationReport(report)
s.byID[report.ID] = stored
s.byFingerprint[report.Fingerprint] = report.ID
s.attachReportToCaseLocked(stored)
return domain.CloneModerationReport(stored), true, nil
}
func (s *ModerationReportStore) attachReportToCaseLocked(report domain.ModerationReport) {
caseID, ok := s.activeCases[report.Target]
if !ok {
item := domain.ModerationCase{
ID: s.nextCaseID, Target: report.Target,
Status: domain.ModerationCaseOpen,
Severity: domain.ModerationSeverityForReason(report.Reason),
Version: 1, ReportCount: 1, DistinctReporterCount: 1,
FirstReportAt: report.CreatedAt, LastReportAt: report.CreatedAt,
CreatedAt: report.CreatedAt, UpdatedAt: report.CreatedAt,
}
s.nextCaseID++
s.cases[item.ID] = item
s.activeCases[item.Target] = item.ID
s.caseReports[item.ID] = []int64{report.ID}
return
}
item := s.cases[caseID]
s.caseReports[caseID] = append(s.caseReports[caseID], report.ID)
item.ReportCount = len(s.caseReports[caseID])
reporters := make(map[int64]struct{}, item.ReportCount)
for _, reportID := range s.caseReports[caseID] {
reporters[s.byID[reportID].ReporterUserID] = struct{}{}
}
item.DistinctReporterCount = len(reporters)
severity := domain.ModerationSeverityForReason(report.Reason)
if severity > item.Severity {
item.Severity = severity
}
if report.CreatedAt.Before(item.FirstReportAt) {
item.FirstReportAt = report.CreatedAt
}
if report.CreatedAt.After(item.LastReportAt) {
item.LastReportAt = report.CreatedAt
}
if report.CreatedAt.After(item.UpdatedAt) {
item.UpdatedAt = report.CreatedAt
}
item.Version++
s.cases[caseID] = item
}
func (s *ModerationReportStore) CreateSponsoredMessageImpression(_ context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error) {
if err := impression.Validate(); err != nil || impression.ID != 0 ||
impression.ReportID != 0 {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
key := memorySponsoredKey{
UserID: impression.UserID, RandomIDHash: impression.RandomIDHash,
}
if existing, found := s.sponsored[key]; found {
return cloneSponsoredImpression(existing), false, nil
}
impression.ID = s.nextSponsoredID
s.nextSponsoredID++
s.sponsored[key] = cloneSponsoredImpression(impression)
return cloneSponsoredImpression(impression), true, nil
}
func (s *ModerationReportStore) GetSponsoredMessageImpression(_ context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error) {
if userID <= 0 || randomIDHash == ([32]byte{}) || now.IsZero() {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
impression, found := s.sponsored[memorySponsoredKey{
UserID: userID, RandomIDHash: randomIDHash,
}]
if !found || !impression.ExpiresAt.After(now) {
return domain.SponsoredMessageImpression{}, false, nil
}
return cloneSponsoredImpression(impression), true, nil
}
func (s *ModerationReportStore) CreateSponsoredModerationReport(_ context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if impressionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
for key, impression := range s.sponsored {
if impression.ID != impressionID {
continue
}
if err := domain.ValidateSponsoredModerationReport(impression, report); err != nil {
return domain.ModerationReport{}, false, err
}
if impression.ReportID != 0 {
existing, found := s.byID[impression.ReportID]
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return domain.CloneModerationReport(existing), false, nil
}
stored, created, err := s.createModerationReportLocked(report)
if err != nil {
return domain.ModerationReport{}, false, err
}
impression.ReportID = stored.ID
s.sponsored[key] = impression
return stored, created, nil
}
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
func (s *ModerationReportStore) CreateChannelAntiSpamDecision(_ context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error) {
if err := decision.Validate(); err != nil || decision.ID != 0 ||
decision.ReportID != 0 {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
key := memoryAntiSpamKey{ChannelID: decision.ChannelID, MessageID: decision.MessageID}
if existing, found := s.antiSpam[key]; found {
return cloneAntiSpamDecision(existing), false, nil
}
decision.ID = s.nextAntiSpamID
s.nextAntiSpamID++
s.antiSpam[key] = cloneAntiSpamDecision(decision)
return cloneAntiSpamDecision(decision), true, nil
}
func (s *ModerationReportStore) GetChannelAntiSpamDecision(_ context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error) {
if channelID <= 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
decision, found := s.antiSpam[memoryAntiSpamKey{
ChannelID: channelID, MessageID: messageID,
}]
return cloneAntiSpamDecision(decision), found, nil
}
func (s *ModerationReportStore) CreateAntiSpamFalsePositiveReport(_ context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if decisionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
for key, decision := range s.antiSpam {
if decision.ID != decisionID {
continue
}
if err := domain.ValidateAntiSpamFalsePositiveReport(decision, report); err != nil {
return domain.ModerationReport{}, false, err
}
if decision.ReportID != 0 {
existing, found := s.byID[decision.ReportID]
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return domain.CloneModerationReport(existing), false, nil
}
stored, created, err := s.createModerationReportLocked(report)
if err != nil {
return domain.ModerationReport{}, false, err
}
decision.ReportID = stored.ID
s.antiSpam[key] = decision
return stored, created, nil
}
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
func (s *ModerationReportStore) DeleteExpiredSponsoredMessageImpressions(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrModerationReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for key, impression := range s.sponsored {
if deleted >= limit {
break
}
if impression.ExpiresAt.Before(olderThan) {
delete(s.sponsored, key)
deleted++
}
}
return deleted, nil
}
func (s *ModerationReportStore) GetModerationReport(_ context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if reportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
report, ok := s.byID[reportID]
if !ok {
return domain.ModerationReport{}, false, nil
}
return domain.CloneModerationReport(report), true, nil
}
func (s *ModerationReportStore) Reports() []domain.ModerationReport {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.ModerationReport, 0, len(s.byID))
for _, report := range s.byID {
out = append(out, domain.CloneModerationReport(report))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *ModerationReportStore) ListModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
if err := filter.Validate(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
statuses := make(map[domain.ModerationCaseStatus]struct{}, len(filter.Statuses))
for _, status := range filter.Statuses {
statuses[status] = struct{}{}
}
out := make([]domain.ModerationCase, 0, filter.Limit)
for _, item := range s.cases {
if len(statuses) > 0 {
if _, ok := statuses[item.Status]; !ok {
continue
}
}
if filter.AssignedTo != "" && item.AssignedTo != filter.AssignedTo {
continue
}
if filter.Target.ID != 0 && item.Target != filter.Target {
continue
}
if !filter.BeforeUpdate.IsZero() &&
(!item.UpdatedAt.Before(filter.BeforeUpdate) &&
(item.UpdatedAt != filter.BeforeUpdate || item.ID >= filter.BeforeID)) {
continue
}
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].UpdatedAt.Equal(out[j].UpdatedAt) {
return out[i].UpdatedAt.After(out[j].UpdatedAt)
}
return out[i].ID > out[j].ID
})
if len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
func (s *ModerationReportStore) GetModerationCase(_ context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
if caseID <= 0 {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.caseDetailLocked(caseID)
}
func (s *ModerationReportStore) caseDetailLocked(caseID int64) (domain.ModerationCaseDetail, bool, error) {
item, ok := s.cases[caseID]
if !ok {
return domain.ModerationCaseDetail{}, false, nil
}
detail := domain.ModerationCaseDetail{
Case: item,
}
for i := len(s.caseReports[caseID]) - 1; i >= 0 &&
len(detail.ReportIDs) < domain.MaxModerationCaseDetailEntries; i-- {
detail.ReportIDs = append(detail.ReportIDs, s.caseReports[caseID][i])
}
for i := len(s.decisions[caseID]) - 1; i >= 0 &&
len(detail.Decisions) < domain.MaxModerationCaseDetailEntries; i-- {
detail.Decisions = append(detail.Decisions, s.decisions[caseID][i])
}
for _, action := range s.actions {
if action.CaseID == caseID {
detail.Actions = append(detail.Actions, cloneModerationAction(action))
}
}
sort.Slice(detail.Actions, func(i, j int) bool { return detail.Actions[i].ID > detail.Actions[j].ID })
if len(detail.Actions) > domain.MaxModerationCaseDetailEntries {
detail.Actions = detail.Actions[:domain.MaxModerationCaseDetailEntries]
}
for _, appeal := range s.appeals {
if appeal.CaseID == caseID {
detail.Appeals = append(detail.Appeals, appeal)
}
}
sort.Slice(detail.Appeals, func(i, j int) bool { return detail.Appeals[i].ID > detail.Appeals[j].ID })
if len(detail.Appeals) > domain.MaxModerationCaseDetailEntries {
detail.Appeals = detail.Appeals[:domain.MaxModerationCaseDetailEntries]
}
return detail, true, nil
}
func (s *ModerationReportStore) ClaimModerationCase(_ context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
if caseID <= 0 || expectedVersion <= 0 || actor == "" ||
len(actor) > domain.MaxModerationActorBytes || now.IsZero() {
return domain.ModerationCase{}, domain.ErrModerationCaseInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.cases[caseID]
if !ok || item.Version != expectedVersion ||
(item.Status != domain.ModerationCaseOpen &&
item.Status != domain.ModerationCaseInReview &&
item.Status != domain.ModerationCaseAppealReview) ||
(item.AssignedTo != "" && item.AssignedTo != actor) {
return domain.ModerationCase{}, domain.ErrModerationCaseConflict
}
if item.Status != domain.ModerationCaseAppealReview {
item.Status = domain.ModerationCaseInReview
}
item.AssignedTo = actor
item.Version++
if now.After(item.UpdatedAt) {
item.UpdatedAt = now
}
s.cases[caseID] = item
return item, nil
}
func (s *ModerationReportStore) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if err := request.Validate(); err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if request.AppealID != 0 {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.decisionCommands[request.CommandID]; ok {
if existing.CaseID != request.CaseID ||
existing.Fingerprint != request.Fingerprint {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationActionConflict
}
detail, found, err := s.caseDetailLocked(existing.CaseID)
return detail, false, requireMemoryCase(found, err)
}
item, ok := s.cases[request.CaseID]
if !ok {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
if item.Version != request.ExpectedVersion ||
(item.Status != domain.ModerationCaseInReview &&
!(item.Status == domain.ModerationCaseActionFailed &&
request.Kind == domain.ModerationDecisionViolation)) ||
item.AssignedTo != request.Actor ||
!memoryModerationActionsValid(item.Target, request.Actions) {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseConflict
}
decision := domain.ModerationDecision{
ID: s.nextDecisionID, CaseID: request.CaseID, AppealID: request.AppealID,
Kind: request.Kind,
Actor: request.Actor, Reason: request.Reason,
CommandID: request.CommandID, Fingerprint: request.Fingerprint,
CreatedAt: request.CreatedAt,
}
s.nextDecisionID++
s.decisions[item.ID] = append(s.decisions[item.ID], decision)
s.decisionCommands[decision.CommandID] = decision
for i, draft := range request.Actions {
action := domain.ModerationAction{
ID: s.nextActionID, CaseID: item.ID, DecisionID: decision.ID,
Kind: draft.Kind, Payload: append([]byte(nil), draft.Payload...),
Status: domain.ModerationActionPending, AvailableAt: request.CreatedAt,
CommandID: fmt.Sprintf("%s:%03d", request.CommandID, i),
CreatedAt: request.CreatedAt, UpdatedAt: request.CreatedAt,
}
s.nextActionID++
s.actions[action.ID] = action
}
delete(s.activeCases, item.Target)
if request.Kind == domain.ModerationDecisionViolation {
item.Status = domain.ModerationCaseActionPending
} else {
item.Status = domain.ModerationCaseDismissed
item.AssignedTo = ""
}
item.Version++
if request.CreatedAt.After(item.UpdatedAt) {
item.UpdatedAt = request.CreatedAt
}
s.cases[item.ID] = item
detail, _, err := s.caseDetailLocked(item.ID)
return detail, true, err
}
func (s *ModerationReportStore) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if err := request.Validate(); err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if request.AppealID <= 0 ||
(request.Kind != domain.ModerationDecisionAppealGrant &&
request.Kind != domain.ModerationDecisionAppealDeny) {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.decisionCommands[request.CommandID]; ok {
if existing.CaseID != request.CaseID ||
existing.Fingerprint != request.Fingerprint {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationActionConflict
}
detail, found, err := s.caseDetailLocked(existing.CaseID)
return detail, false, requireMemoryCase(found, err)
}
item, ok := s.cases[request.CaseID]
if !ok {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
appeal, ok := s.appeals[request.AppealID]
if !ok || appeal.CaseID != item.ID {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
if item.Version != request.ExpectedVersion ||
item.Status != domain.ModerationCaseAppealReview ||
item.AssignedTo != request.Actor ||
appeal.Status != domain.ModerationAppealPending ||
!memoryModerationActionsValid(item.Target, request.Actions) {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseConflict
}
if request.Kind == domain.ModerationDecisionAppealGrant &&
!s.memoryCaseOwnsRemedySanctionsLocked(item, request.Actions) {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationActionConflict
}
decision := domain.ModerationDecision{
ID: s.nextDecisionID, CaseID: request.CaseID, AppealID: request.AppealID,
Kind: request.Kind, Actor: request.Actor, Reason: request.Reason,
CommandID: request.CommandID, Fingerprint: request.Fingerprint,
CreatedAt: request.CreatedAt,
}
s.nextDecisionID++
s.decisions[item.ID] = append(s.decisions[item.ID], decision)
s.decisionCommands[decision.CommandID] = decision
for i, draft := range request.Actions {
action := domain.ModerationAction{
ID: s.nextActionID, CaseID: item.ID, DecisionID: decision.ID,
Kind: draft.Kind, Payload: append([]byte(nil), draft.Payload...),
Status: domain.ModerationActionPending, AvailableAt: request.CreatedAt,
CommandID: fmt.Sprintf("%s:%03d", request.CommandID, i),
CreatedAt: request.CreatedAt, UpdatedAt: request.CreatedAt,
}
s.nextActionID++
s.actions[action.ID] = action
}
appeal.Reviewer = request.Actor
appeal.ReviewReason = request.Reason
appeal.ReviewedAt = request.CreatedAt
if request.Kind == domain.ModerationDecisionAppealGrant {
appeal.Status = domain.ModerationAppealGranted
item.Status = domain.ModerationCaseDismissed
if len(request.Actions) > 0 {
item.Status = domain.ModerationCaseActionPending
}
} else {
appeal.Status = domain.ModerationAppealRejected
item.Status = appeal.PreviousCaseStatus
}
if item.Status != domain.ModerationCaseActionPending {
item.AssignedTo = ""
}
item.Version++
if request.CreatedAt.After(item.UpdatedAt) {
item.UpdatedAt = request.CreatedAt
}
s.appeals[appeal.ID] = appeal
s.cases[item.ID] = item
detail, _, err := s.caseDetailLocked(item.ID)
return detail, true, err
}
func (s *ModerationReportStore) CreateModerationAppeal(_ context.Context, appeal domain.ModerationAppeal) (domain.ModerationAppeal, bool, error) {
if err := appeal.Validate(); err != nil || appeal.ID != 0 ||
appeal.Status != domain.ModerationAppealPending {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.appealFingerprints[appeal.Fingerprint]; ok {
return s.appeals[id], false, nil
}
item, ok := s.cases[appeal.CaseID]
if !ok {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
}
if item.Status != domain.ModerationCaseResolved &&
item.Status != domain.ModerationCaseDismissed {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseConflict
}
if item.Target.Type == domain.PeerTypeUser &&
item.Target.ID != appeal.AppellantUserID {
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
}
for _, existing := range s.appeals {
if existing.CaseID == appeal.CaseID &&
existing.AppellantUserID == appeal.AppellantUserID &&
existing.Status == domain.ModerationAppealPending {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseConflict
}
}
appeal.ID = s.nextAppealID
s.nextAppealID++
s.appeals[appeal.ID] = appeal
s.appealFingerprints[appeal.Fingerprint] = appeal.ID
item.Status = domain.ModerationCaseAppealReview
item.AssignedTo = ""
item.Version++
if appeal.CreatedAt.After(item.UpdatedAt) {
item.UpdatedAt = appeal.CreatedAt
}
s.cases[item.ID] = item
return appeal, true, nil
}
func (s *ModerationReportStore) GetModerationAppeal(_ context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
if appealID <= 0 {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
appeal, found := s.appeals[appealID]
return appeal, found, nil
}
func (s *ModerationReportStore) IssueModerationAppealLink(_ context.Context, link domain.ModerationAppealLink) (domain.ModerationAppealLink, error) {
if err := link.Validate(); err != nil || link.ID != 0 ||
link.AppealID != 0 || !link.ConsumedAt.IsZero() {
return domain.ModerationAppealLink{}, domain.ErrModerationAppealLinkInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.appealLinks[link.TokenHash]; exists {
return domain.ModerationAppealLink{}, domain.ErrModerationActionConflict
}
item, ok := s.cases[link.CaseID]
if !ok {
return domain.ModerationAppealLink{}, domain.ErrModerationCaseNotFound
}
if item.Target.Type != domain.PeerTypeUser ||
item.Target.ID != link.AppellantUserID {
return domain.ModerationAppealLink{}, domain.ErrModerationPermissionDenied
}
if item.Status != domain.ModerationCaseActionPending &&
item.Status != domain.ModerationCaseResolved {
return domain.ModerationAppealLink{}, domain.ErrModerationCaseConflict
}
var caseLinks int
for _, existing := range s.appealLinks {
if existing.CaseID == link.CaseID {
caseLinks++
}
}
if caseLinks >= domain.MaxModerationAppealLinksPerCase {
return domain.ModerationAppealLink{}, domain.ErrModerationActionConflict
}
link.ID = s.nextAppealLinkID
s.nextAppealLinkID++
s.appealLinks[link.TokenHash] = link
return link, nil
}
func (s *ModerationReportStore) GetModerationAppealLink(_ context.Context, tokenHash [32]byte, now time.Time) (domain.ModerationAppealLink, bool, error) {
if tokenHash == ([32]byte{}) || now.IsZero() {
return domain.ModerationAppealLink{}, false, domain.ErrModerationAppealLinkInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
link, found := s.appealLinks[tokenHash]
if !found || !link.ExpiresAt.After(now) {
return domain.ModerationAppealLink{}, false, nil
}
return link, true, nil
}
func (s *ModerationReportStore) SubmitModerationAppealByLink(_ context.Context, tokenHash [32]byte, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
if tokenHash == ([32]byte{}) || now.IsZero() {
return domain.ModerationAppeal{}, false, domain.ErrModerationAppealLinkInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
link, found := s.appealLinks[tokenHash]
if !found || !link.ExpiresAt.After(now) {
return domain.ModerationAppeal{}, false, domain.ErrModerationAppealLinkInvalid
}
if link.AppealID > 0 {
appeal, ok := s.appeals[link.AppealID]
if !ok {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
}
return appeal, false, nil
}
item, ok := s.cases[link.CaseID]
if !ok {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
}
if item.Target.Type != domain.PeerTypeUser ||
item.Target.ID != link.AppellantUserID {
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
}
for _, existing := range s.appeals {
if existing.CaseID == link.CaseID &&
existing.AppellantUserID == link.AppellantUserID &&
existing.Status == domain.ModerationAppealPending {
link.AppealID = existing.ID
link.ConsumedAt = now
s.appealLinks[tokenHash] = link
return existing, false, nil
}
}
if item.Status != domain.ModerationCaseResolved &&
item.Status != domain.ModerationCaseDismissed {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseConflict
}
appeal, err := domain.NewModerationAppeal(
item.ID, link.AppellantUserID, item.Status, text, now,
)
if err != nil {
return domain.ModerationAppeal{}, false, err
}
appeal.ID = s.nextAppealID
s.nextAppealID++
s.appeals[appeal.ID] = appeal
s.appealFingerprints[appeal.Fingerprint] = appeal.ID
item.Status = domain.ModerationCaseAppealReview
item.AssignedTo = ""
item.Version++
if now.After(item.UpdatedAt) {
item.UpdatedAt = now
}
s.cases[item.ID] = item
link.AppealID = appeal.ID
link.ConsumedAt = now
s.appealLinks[tokenHash] = link
return appeal, true, nil
}
func (s *ModerationReportStore) DeleteExpiredModerationAppealLinks(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrModerationAppealLinkInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for tokenHash, link := range s.appealLinks {
if deleted >= limit {
break
}
if link.ExpiresAt.Before(olderThan) {
delete(s.appealLinks, tokenHash)
deleted++
}
}
return deleted, nil
}
func (s *ModerationReportStore) ClaimModerationActions(_ context.Context, now time.Time, limit int, lease time.Duration) ([]domain.ModerationAction, error) {
if now.IsZero() || limit <= 0 || limit > 100 || lease <= 0 {
return nil, domain.ErrModerationActionInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
candidates := make([]domain.ModerationAction, 0)
for _, action := range s.actions {
if action.Attempts >= domain.MaxModerationActionAttempts {
continue
}
if (action.Status == domain.ModerationActionPending ||
action.Status == domain.ModerationActionRetry) &&
!action.AvailableAt.After(now) ||
action.Status == domain.ModerationActionProcessing &&
!action.LeaseUntil.After(now) {
candidates = append(candidates, action)
}
}
sort.Slice(candidates, func(i, j int) bool {
if !candidates[i].AvailableAt.Equal(candidates[j].AvailableAt) {
return candidates[i].AvailableAt.Before(candidates[j].AvailableAt)
}
return candidates[i].ID < candidates[j].ID
})
if len(candidates) > limit {
candidates = candidates[:limit]
}
for i := range candidates {
action := candidates[i]
action.Status = domain.ModerationActionProcessing
action.Attempts++
action.LeaseUntil = now.Add(lease)
action.UpdatedAt = now
s.actions[action.ID] = action
candidates[i] = cloneModerationAction(action)
}
return candidates, nil
}
func (s *ModerationReportStore) IsModerationActionCurrent(_ context.Context, action domain.ModerationAction) (bool, error) {
if action.ID <= 0 || action.CaseID <= 0 {
return false, domain.ErrModerationActionInvalid
}
family, scoped := action.Kind.SanctionFamily()
if !scoped {
return true, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.actions[action.ID]
if !ok || item.CaseID != action.CaseID || item.Kind != action.Kind {
return false, domain.ErrModerationActionConflict
}
targetCase, ok := s.cases[action.CaseID]
if !ok {
return false, domain.ErrModerationCaseNotFound
}
latest, found := s.latestSanctionActionLocked(targetCase.Target, family)
return found && latest.ID == action.ID, nil
}
func (s *ModerationReportStore) SupersedeModerationAction(_ context.Context, actionID int64, expectedAttempts int, now time.Time) error {
if actionID <= 0 || expectedAttempts <= 0 || now.IsZero() {
return domain.ErrModerationActionInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
action, ok := s.actions[actionID]
if !ok || action.Status != domain.ModerationActionProcessing ||
action.Attempts != expectedAttempts {
return domain.ErrModerationActionConflict
}
action.Status = domain.ModerationActionSuperseded
action.LeaseUntil = time.Time{}
action.LastError = ""
action.UpdatedAt = now
s.actions[action.ID] = action
s.finishModerationDecisionLocked(action, now)
return nil
}
func (s *ModerationReportStore) CompleteModerationAction(_ context.Context, actionID int64, expectedAttempts int, succeeded bool, errorText string, retryAt, now time.Time) error {
if actionID <= 0 || expectedAttempts <= 0 || len(errorText) > 4000 ||
now.IsZero() || (!succeeded && retryAt.Before(now)) {
return domain.ErrModerationActionInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
action, ok := s.actions[actionID]
if !ok || action.Status != domain.ModerationActionProcessing ||
action.Attempts != expectedAttempts {
return domain.ErrModerationActionConflict
}
action.LeaseUntil = time.Time{}
action.UpdatedAt = now
if succeeded {
action.Status = domain.ModerationActionSucceeded
action.LastError = ""
} else {
action.LastError = errorText
action.AvailableAt = retryAt
action.Status = domain.ModerationActionRetry
if action.Attempts >= domain.MaxModerationActionAttempts {
action.Status = domain.ModerationActionFailed
}
}
s.actions[action.ID] = action
item := s.cases[action.CaseID]
if action.Status == domain.ModerationActionFailed {
item.Status = domain.ModerationCaseActionFailed
item.Version++
item.UpdatedAt = now
s.cases[item.ID] = item
return nil
}
if !succeeded {
return nil
}
s.finishModerationDecisionLocked(action, now)
return nil
}
func (s *ModerationReportStore) finishModerationDecisionLocked(action domain.ModerationAction, now time.Time) {
allTerminal := true
for _, candidate := range s.actions {
if candidate.DecisionID == action.DecisionID &&
candidate.Status != domain.ModerationActionSucceeded &&
candidate.Status != domain.ModerationActionSuperseded {
allTerminal = false
break
}
}
item := s.cases[action.CaseID]
if allTerminal && item.Status == domain.ModerationCaseActionPending {
item.Status = domain.ModerationCaseResolved
for _, decision := range s.decisions[item.ID] {
if decision.ID == action.DecisionID &&
decision.Kind == domain.ModerationDecisionAppealGrant {
item.Status = domain.ModerationCaseDismissed
break
}
}
item.AssignedTo = ""
item.Version++
item.UpdatedAt = now
s.cases[item.ID] = item
}
}
func (s *ModerationReportStore) latestSanctionActionLocked(target domain.Peer, family domain.ModerationSanctionFamily) (domain.ModerationAction, bool) {
var latest domain.ModerationAction
for _, action := range s.actions {
item, ok := s.cases[action.CaseID]
if !ok || item.Target != target {
continue
}
actionFamily, scoped := action.Kind.SanctionFamily()
if !scoped || actionFamily != family || action.ID <= latest.ID {
continue
}
latest = action
}
return latest, latest.ID > 0
}
func (s *ModerationReportStore) memoryCaseOwnsRemedySanctionsLocked(item domain.ModerationCase, actions []domain.ModerationActionDraft) bool {
for _, action := range actions {
family, scoped := action.Kind.SanctionFamily()
if !scoped {
continue
}
latest, found := s.latestSanctionActionLocked(item.Target, family)
if !found || latest.CaseID != item.ID {
return false
}
switch action.Kind {
case domain.ModerationActionClearPeerFlags:
if latest.Kind != domain.ModerationActionMarkScam &&
latest.Kind != domain.ModerationActionMarkFake {
return false
}
case domain.ModerationActionUnfreezeAccount:
if latest.Kind != domain.ModerationActionFreezeAccount {
return false
}
case domain.ModerationActionUnrestrictAccount:
if latest.Kind != domain.ModerationActionRestrictAccount {
return false
}
default:
return false
}
}
return true
}
func memoryModerationActionsValid(target domain.Peer, actions []domain.ModerationActionDraft) bool {
for _, action := range actions {
switch action.Kind {
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
domain.ModerationActionClearPeerFlags:
case domain.ModerationActionFreezeAccount,
domain.ModerationActionUnfreezeAccount,
domain.ModerationActionRestrictAccount,
domain.ModerationActionUnrestrictAccount,
domain.ModerationActionDeletePrivateMessage,
domain.ModerationActionDeleteAccount:
if target.Type != domain.PeerTypeUser {
return false
}
case domain.ModerationActionDeleteChannelMessage:
if target.Type != domain.PeerTypeChannel {
return false
}
default:
return false
}
}
return true
}
func cloneModerationAction(action domain.ModerationAction) domain.ModerationAction {
action.Payload = append([]byte(nil), action.Payload...)
return action
}
func cloneSponsoredImpression(impression domain.SponsoredMessageImpression) domain.SponsoredMessageImpression {
impression.Evidence = append([]byte(nil), impression.Evidence...)
return impression
}
func cloneAntiSpamDecision(decision domain.ChannelAntiSpamDecision) domain.ChannelAntiSpamDecision {
decision.Evidence = append([]byte(nil), decision.Evidence...)
return decision
}
func requireMemoryCase(found bool, err error) error {
if err != nil {
return err
}
if !found {
return domain.ErrModerationCaseNotFound
}
return nil
}