veza/veza-backend-api/internal/services/moderation_service.go

786 lines
26 KiB
Go
Raw Normal View History

package services
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ModerationService provides advanced moderation capabilities (F411-F420)
// All moderation decisions are ALWAYS human-final — automated rules only flag content.
type ModerationService struct {
db *gorm.DB
logger *zap.Logger
}
// NewModerationService creates a new moderation service
func NewModerationService(db *gorm.DB, logger *zap.Logger) *ModerationService {
if logger == nil {
logger = zap.NewNop()
}
return &ModerationService{db: db, logger: logger}
}
// --- F411: Moderation Queue ---
// ModerationQueueItem represents an item in the moderation queue
type ModerationQueueItem struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
ReportedUserID *string `json:"reported_user_id,omitempty"`
ContentType string `json:"content_type"`
ContentID *string `json:"content_id,omitempty"`
Reason string `json:"reason"`
Category string `json:"category"`
Priority string `json:"priority"`
Status string `json:"status"`
AssignedTo *string `json:"assigned_to,omitempty"`
ResolutionNote string `json:"resolution_note,omitempty"`
ResolutionAction string `json:"resolution_action,omitempty"`
ResolvedBy *string `json:"resolved_by,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ReporterName string `json:"reporter_name,omitempty"`
ReportedName string `json:"reported_name,omitempty"`
}
// ModerationQueueParams holds filtering parameters for the queue
type ModerationQueueParams struct {
Status string
Category string
Priority string
Limit int
Offset int
SortBy string // "created_at", "priority"
}
// GetModerationQueue returns the moderation queue with filters (F411)
func (s *ModerationService) GetModerationQueue(ctx context.Context, params ModerationQueueParams) ([]ModerationQueueItem, int64, error) {
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
query := s.db.WithContext(ctx).Table("reports r").
Select(`r.id, r.reporter_id, r.reported_user_id, r.content_type, r.content_id,
r.reason, r.category, r.priority, r.status, r.assigned_to,
r.resolution_note, r.resolution_action, r.resolved_by, r.resolved_at, r.created_at,
reporter.username AS reporter_name,
reported.username AS reported_name`).
Joins("LEFT JOIN users reporter ON reporter.id = r.reporter_id").
Joins("LEFT JOIN users reported ON reported.id = r.reported_user_id")
if params.Status != "" && params.Status != "all" {
query = query.Where("r.status = ?", params.Status)
}
if params.Category != "" {
query = query.Where("r.category = ?", params.Category)
}
if params.Priority != "" {
query = query.Where("r.priority = ?", params.Priority)
}
var total int64
countQuery := s.db.WithContext(ctx).Table("reports")
if params.Status != "" && params.Status != "all" {
countQuery = countQuery.Where("status = ?", params.Status)
}
if params.Category != "" {
countQuery = countQuery.Where("category = ?", params.Category)
}
if params.Priority != "" {
countQuery = countQuery.Where("priority = ?", params.Priority)
}
countQuery.Count(&total)
orderBy := "r.created_at DESC"
if params.SortBy == "priority" {
orderBy = "CASE r.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END, r.created_at DESC"
}
var items []ModerationQueueItem
if err := query.Order(orderBy).Offset(params.Offset).Limit(params.Limit).Scan(&items).Error; err != nil {
return nil, 0, fmt.Errorf("failed to get moderation queue: %w", err)
}
return items, total, nil
}
// ModerationAction represents an action taken on a report
type ModerationAction struct {
Action string `json:"action" binding:"required"` // approve, reject, ban_temp, ban_perm, warn, dismiss
Reason string `json:"reason"`
BanDurationDays int `json:"ban_duration_days,omitempty"` // for ban_temp
}
// ProcessReport processes a moderation action on a report (F411)
// The decision is ALWAYS human — this just records and executes the moderator's decision.
func (s *ModerationService) ProcessReport(ctx context.Context, reportID uuid.UUID, moderatorID uuid.UUID, action ModerationAction) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Get the report
var report struct {
ID uuid.UUID `gorm:"column:id"`
Status string `gorm:"column:status"`
ReportedUserID *uuid.UUID `gorm:"column:reported_user_id"`
ContentType string `gorm:"column:content_type"`
ContentID *uuid.UUID `gorm:"column:content_id"`
}
if err := tx.Table("reports").Where("id = ?", reportID).First(&report).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("report not found")
}
return fmt.Errorf("failed to get report: %w", err)
}
if report.Status != "pending" {
return fmt.Errorf("report already processed")
}
now := time.Now()
status := "resolved"
if action.Action == "dismiss" {
status = "dismissed"
}
// Update the report
if err := tx.Table("reports").Where("id = ?", reportID).Updates(map[string]interface{}{
"status": status,
"resolved_by": moderatorID,
"resolved_at": now,
"resolution_note": action.Reason,
"resolution_action": action.Action,
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("failed to update report: %w", err)
}
// Log the moderation action
if err := tx.Exec(`
INSERT INTO moderation_actions (moderator_id, target_user_id, target_content_type, target_content_id, action, reason)
VALUES (?, ?, ?, ?, ?, ?)
`, moderatorID, report.ReportedUserID, report.ContentType, report.ContentID, action.Action, action.Reason).Error; err != nil {
s.logger.Warn("Failed to log moderation action", zap.Error(err))
}
// Execute the action
switch action.Action {
case "reject":
// Hide the content
if report.ContentID != nil {
s.hideContent(tx, report.ContentType, *report.ContentID)
}
// Issue a strike if reported user exists
if report.ReportedUserID != nil {
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "minor")
}
case "ban_temp":
if report.ReportedUserID != nil {
duration := action.BanDurationDays
if duration <= 0 {
duration = 7
}
s.suspendUser(tx, *report.ReportedUserID, moderatorID, action.Reason, &duration)
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "major")
}
case "ban_perm":
if report.ReportedUserID != nil {
s.suspendUser(tx, *report.ReportedUserID, moderatorID, action.Reason, nil)
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "major")
}
case "warn":
if report.ReportedUserID != nil {
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "warning")
}
}
return nil
})
}
// AssignReport assigns a report to a moderator (F411)
func (s *ModerationService) AssignReport(ctx context.Context, reportID uuid.UUID, moderatorID uuid.UUID) error {
result := s.db.WithContext(ctx).Table("reports").Where("id = ?", reportID).Updates(map[string]interface{}{
"assigned_to": moderatorID,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to assign report: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("report not found")
}
return nil
}
// --- F412: Enhanced Reporting ---
// EnhancedReportRequest is the request for creating a report with categories
type EnhancedReportRequest struct {
ContentType string `json:"content_type" binding:"required"` // track, comment, profile, message
ContentID *uuid.UUID `json:"content_id"`
ReportedUserID *uuid.UUID `json:"reported_user_id"`
Category string `json:"category" binding:"required"` // spam, offensive, copyright, fake, other
Reason string `json:"reason" binding:"required"`
}
// CreateEnhancedReport creates a report with category and priority (F412)
func (s *ModerationService) CreateEnhancedReport(ctx context.Context, reporterID uuid.UUID, req EnhancedReportRequest) (uuid.UUID, error) {
validCategories := map[string]bool{"spam": true, "offensive": true, "copyright": true, "fake": true, "other": true}
if !validCategories[req.Category] {
return uuid.Nil, fmt.Errorf("invalid category: %s", req.Category)
}
validContentTypes := map[string]bool{"track": true, "comment": true, "profile": true, "message": true}
if !validContentTypes[req.ContentType] {
return uuid.Nil, fmt.Errorf("invalid content type: %s", req.ContentType)
}
// Determine priority based on category
priority := "normal"
switch req.Category {
case "copyright":
priority = "high"
case "offensive":
priority = "high"
case "spam":
priority = "normal"
case "fake":
priority = "normal"
}
id := uuid.New()
if err := s.db.WithContext(ctx).Exec(`
INSERT INTO reports (id, reporter_id, reported_user_id, content_type, content_id, reason, category, priority, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())
`, id, reporterID, req.ReportedUserID, req.ContentType, req.ContentID, req.Reason, req.Category, priority).Error; err != nil {
return uuid.Nil, fmt.Errorf("failed to create report: %w", err)
}
return id, nil
}
// --- F413: Deterministic Spam Detection ---
// SpamCheckResult represents the result of a spam check
type SpamCheckResult struct {
IsSpam bool `json:"is_spam"`
Detections []SpamDetection `json:"detections"`
ActionTaken string `json:"action_taken"` // "none", "flagged", "auto_hidden"
}
// SpamDetection represents a single spam detection
type SpamDetection struct {
RuleName string `json:"rule_name"`
RuleType string `json:"rule_type"`
Severity string `json:"severity"`
Details string `json:"details"`
}
// CheckForSpam runs deterministic spam checks on content (F413)
// No ML — only explicit rules.
func (s *ModerationService) CheckForSpam(ctx context.Context, userID uuid.UUID, contentType string, contentID uuid.UUID, content string) (*SpamCheckResult, error) {
result := &SpamCheckResult{
Detections: []SpamDetection{},
}
// Load active rules
var rules []struct {
ID uuid.UUID `gorm:"column:id"`
Name string `gorm:"column:name"`
RuleType string `gorm:"column:rule_type"`
Config string `gorm:"column:config"`
Severity string `gorm:"column:severity"`
}
if err := s.db.WithContext(ctx).Raw(`
SELECT id, name, rule_type, config::text, severity
FROM spam_rules WHERE is_active = TRUE
`).Scan(&rules).Error; err != nil {
return nil, fmt.Errorf("failed to load spam rules: %w", err)
}
for _, rule := range rules {
var config map[string]interface{}
json.Unmarshal([]byte(rule.Config), &config)
detected := false
details := ""
switch rule.RuleType {
case "duplicate_content":
detected, details = s.checkDuplicateContent(ctx, userID, contentType, content, config)
case "excessive_links":
detected, details = s.checkExcessiveLinks(content, config)
case "bot_pattern":
detected, details = s.checkBotPattern(ctx, userID, contentType, config)
}
if detected {
result.IsSpam = true
result.Detections = append(result.Detections, SpamDetection{
RuleName: rule.Name,
RuleType: rule.RuleType,
Severity: rule.Severity,
Details: details,
})
// Log the detection
detailsJSON, _ := json.Marshal(map[string]string{"details": details, "rule_name": rule.Name})
s.db.WithContext(ctx).Exec(`
INSERT INTO spam_detections (user_id, rule_id, content_type, content_id, details, action_taken)
VALUES (?, ?, ?, ?, ?::jsonb, 'flagged')
`, userID, rule.ID, contentType, contentID, string(detailsJSON))
}
}
if result.IsSpam {
result.ActionTaken = "flagged"
} else {
result.ActionTaken = "none"
}
return result, nil
}
// checkDuplicateContent checks for duplicate titles/descriptions (F413a)
func (s *ModerationService) checkDuplicateContent(ctx context.Context, userID uuid.UUID, contentType string, content string, config map[string]interface{}) (bool, string) {
windowHours := 24
if v, ok := config["window_hours"].(float64); ok {
windowHours = int(v)
}
minDuplicates := 3
if v, ok := config["min_duplicates"].(float64); ok {
minDuplicates = int(v)
}
if contentType == "track" {
var count int64
s.db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM tracks
WHERE creator_id = ? AND LOWER(title) = LOWER(?) AND deleted_at IS NULL
AND created_at > NOW() - INTERVAL '1 hour' * ?
`, userID, content, windowHours).Scan(&count)
if count >= int64(minDuplicates) {
return true, fmt.Sprintf("Found %d tracks with identical title in last %dh", count, windowHours)
}
}
return false, ""
}
// checkExcessiveLinks checks for too many URLs in content (F413b)
func (s *ModerationService) checkExcessiveLinks(content string, config map[string]interface{}) (bool, string) {
maxLinks := 5
if v, ok := config["max_links"].(float64); ok {
maxLinks = int(v)
}
// Count URLs in content
linkCount := 0
words := strings.Fields(content)
for _, word := range words {
if _, err := url.ParseRequestURI(word); err == nil && (strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://")) {
linkCount++
}
}
if linkCount > maxLinks {
return true, fmt.Sprintf("Found %d links (max: %d)", linkCount, maxLinks)
}
return false, ""
}
// checkBotPattern checks for bot-like behavior patterns (F413c)
func (s *ModerationService) checkBotPattern(ctx context.Context, userID uuid.UUID, contentType string, config map[string]interface{}) (bool, string) {
switch contentType {
case "track":
maxPerHour := 10
if v, ok := config["max_uploads_per_hour"].(float64); ok {
maxPerHour = int(v)
}
var count int64
s.db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM tracks
WHERE creator_id = ? AND deleted_at IS NULL AND created_at > NOW() - INTERVAL '1 hour'
`, userID).Scan(&count)
if count >= int64(maxPerHour) {
return true, fmt.Sprintf("Uploaded %d tracks in last hour (max: %d)", count, maxPerHour)
}
case "comment":
maxPerMinute := 5
if v, ok := config["max_comments_per_minute"].(float64); ok {
maxPerMinute = int(v)
}
var count int64
s.db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM comments
WHERE user_id = ? AND deleted_at IS NULL AND created_at > NOW() - INTERVAL '1 minute'
`, userID).Scan(&count)
if count >= int64(maxPerMinute) {
return true, fmt.Sprintf("Posted %d comments in last minute (max: %d)", count, maxPerMinute)
}
}
return false, ""
}
// --- F414: Audio Fingerprinting ---
// FingerprintResult represents the result of an audio fingerprint check
type FingerprintResult struct {
TrackID string `json:"track_id"`
Status string `json:"status"`
MatchedTitle string `json:"matched_title,omitempty"`
MatchedArtist string `json:"matched_artist,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
// RecordFingerprintResult stores fingerprint result for a track (F414)
// In production, this would be called after ACRCloud API response.
// The result is ALWAYS reviewed by a human before any action.
func (s *ModerationService) RecordFingerprintResult(ctx context.Context, trackID uuid.UUID, status string, matchedTitle, matchedArtist, matchedAlbum string, confidence float64, externalID string, rawResponse map[string]interface{}) error {
rawJSON, _ := json.Marshal(rawResponse)
return s.db.WithContext(ctx).Exec(`
INSERT INTO audio_fingerprints (track_id, status, matched_title, matched_artist, matched_album, confidence, external_id, raw_response)
VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb)
ON CONFLICT (track_id) DO UPDATE SET
status = EXCLUDED.status,
matched_title = EXCLUDED.matched_title,
matched_artist = EXCLUDED.matched_artist,
matched_album = EXCLUDED.matched_album,
confidence = EXCLUDED.confidence,
external_id = EXCLUDED.external_id,
raw_response = EXCLUDED.raw_response,
updated_at = NOW()
`, trackID, status, matchedTitle, matchedArtist, matchedAlbum, confidence, externalID, string(rawJSON)).Error
}
// GetPendingFingerprints returns fingerprints awaiting human review (F414)
func (s *ModerationService) GetPendingFingerprints(ctx context.Context, limit, offset int) ([]FingerprintResult, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var total int64
s.db.WithContext(ctx).Table("audio_fingerprints").Where("status = 'matched' AND reviewed = FALSE").Count(&total)
var rows []struct {
TrackID string `gorm:"column:track_id"`
Status string `gorm:"column:status"`
MatchedTitle string `gorm:"column:matched_title"`
MatchedArtist string `gorm:"column:matched_artist"`
Confidence float64 `gorm:"column:confidence"`
}
if err := s.db.WithContext(ctx).Raw(`
SELECT CAST(track_id AS TEXT) AS track_id, status, matched_title, matched_artist, confidence
FROM audio_fingerprints
WHERE status = 'matched' AND reviewed = FALSE
ORDER BY confidence DESC, created_at ASC
LIMIT ? OFFSET ?
`, limit, offset).Scan(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to get pending fingerprints: %w", err)
}
results := make([]FingerprintResult, len(rows))
for i, r := range rows {
results[i] = FingerprintResult{
TrackID: r.TrackID,
Status: r.Status,
MatchedTitle: r.MatchedTitle,
MatchedArtist: r.MatchedArtist,
Confidence: r.Confidence,
}
}
return results, total, nil
}
// ReviewFingerprint marks a fingerprint as reviewed (F414)
func (s *ModerationService) ReviewFingerprint(ctx context.Context, trackID uuid.UUID, reviewerID uuid.UUID, newStatus string) error {
validStatuses := map[string]bool{"clean": true, "matched": true}
if !validStatuses[newStatus] {
return fmt.Errorf("invalid status: %s", newStatus)
}
now := time.Now()
result := s.db.WithContext(ctx).Table("audio_fingerprints").Where("track_id = ?", trackID).Updates(map[string]interface{}{
"status": newStatus,
"reviewed": true,
"reviewed_by": reviewerID,
"reviewed_at": now,
"updated_at": now,
})
if result.Error != nil {
return fmt.Errorf("failed to review fingerprint: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("fingerprint not found")
}
return nil
}
// --- F415: Strike System ---
// StrikeInfo represents a strike on a user's account
type StrikeInfo struct {
ID string `json:"id"`
Reason string `json:"reason"`
Severity string `json:"severity"`
IsActive bool `json:"is_active"`
Appealed bool `json:"appealed"`
AppealResult *string `json:"appeal_result,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// UserStrikeSummary summarizes a user's strike history
type UserStrikeSummary struct {
UserID string `json:"user_id"`
ActiveStrikes int `json:"active_strikes"`
TotalStrikes int `json:"total_strikes"`
IsSuspended bool `json:"is_suspended"`
SuspendedUntil *time.Time `json:"suspended_until,omitempty"`
Strikes []StrikeInfo `json:"strikes"`
}
// GetUserStrikes returns strike summary for a user (F415)
func (s *ModerationService) GetUserStrikes(ctx context.Context, userID uuid.UUID) (*UserStrikeSummary, error) {
summary := &UserStrikeSummary{
UserID: userID.String(),
Strikes: []StrikeInfo{},
}
var strikes []struct {
ID uuid.UUID `gorm:"column:id"`
Reason string `gorm:"column:reason"`
Severity string `gorm:"column:severity"`
IsActive bool `gorm:"column:is_active"`
Appealed bool `gorm:"column:appealed"`
AppealResult *string `gorm:"column:appeal_result"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedAt time.Time `gorm:"column:created_at"`
}
if err := s.db.WithContext(ctx).Raw(`
SELECT id, reason, severity, is_active, appealed, appeal_result, expires_at, created_at
FROM user_strikes
WHERE user_id = ?
ORDER BY created_at DESC
`, userID).Scan(&strikes).Error; err != nil {
return nil, fmt.Errorf("failed to get strikes: %w", err)
}
for _, st := range strikes {
info := StrikeInfo{
ID: st.ID.String(),
Reason: st.Reason,
Severity: st.Severity,
IsActive: st.IsActive,
Appealed: st.Appealed,
AppealResult: st.AppealResult,
ExpiresAt: st.ExpiresAt,
CreatedAt: st.CreatedAt,
}
summary.Strikes = append(summary.Strikes, info)
summary.TotalStrikes++
if st.IsActive {
summary.ActiveStrikes++
}
}
// Check suspension status
var suspension struct {
IsActive bool `gorm:"column:is_active"`
SuspendedUntil *time.Time `gorm:"column:suspended_until"`
}
if err := s.db.WithContext(ctx).Raw(`
SELECT is_active, suspended_until
FROM user_suspensions
WHERE user_id = ? AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1
`, userID).Scan(&suspension).Error; err == nil {
summary.IsSuspended = suspension.IsActive
summary.SuspendedUntil = suspension.SuspendedUntil
}
return summary, nil
}
// AppealStrike records an appeal for a strike (F415)
func (s *ModerationService) AppealStrike(ctx context.Context, userID uuid.UUID, strikeID uuid.UUID, appealText string) error {
if strings.TrimSpace(appealText) == "" {
return fmt.Errorf("appeal text is required")
}
result := s.db.WithContext(ctx).Exec(`
UPDATE user_strikes SET appealed = TRUE, appeal_text = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND is_active = TRUE AND appealed = FALSE
`, appealText, strikeID, userID)
if result.Error != nil {
return fmt.Errorf("failed to submit appeal: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("strike not found or already appealed")
}
return nil
}
// ResolveAppeal resolves a strike appeal (F415)
func (s *ModerationService) ResolveAppeal(ctx context.Context, strikeID uuid.UUID, resolverID uuid.UUID, upheld bool) error {
result := "upheld"
if !upheld {
result = "overturned"
}
now := time.Now()
updates := map[string]interface{}{
"appeal_resolved": true,
"appeal_result": result,
"appeal_resolved_by": resolverID,
"appeal_resolved_at": now,
"updated_at": now,
}
if !upheld {
updates["is_active"] = false
}
res := s.db.WithContext(ctx).Table("user_strikes").Where("id = ? AND appealed = TRUE AND appeal_resolved = FALSE", strikeID).Updates(updates)
if res.Error != nil {
return fmt.Errorf("failed to resolve appeal: %w", res.Error)
}
if res.RowsAffected == 0 {
return fmt.Errorf("appeal not found or already resolved")
}
return nil
}
// GetPendingAppeals returns strikes with pending appeals (F415)
func (s *ModerationService) GetPendingAppeals(ctx context.Context, limit, offset int) ([]StrikeInfo, int64, error) {
if limit <= 0 {
limit = 20
}
var total int64
s.db.WithContext(ctx).Table("user_strikes").Where("appealed = TRUE AND appeal_resolved = FALSE").Count(&total)
var rows []struct {
ID uuid.UUID `gorm:"column:id"`
Reason string `gorm:"column:reason"`
Severity string `gorm:"column:severity"`
IsActive bool `gorm:"column:is_active"`
Appealed bool `gorm:"column:appealed"`
CreatedAt time.Time `gorm:"column:created_at"`
}
if err := s.db.WithContext(ctx).Raw(`
SELECT id, reason, severity, is_active, appealed, created_at
FROM user_strikes
WHERE appealed = TRUE AND appeal_resolved = FALSE
ORDER BY created_at ASC
LIMIT ? OFFSET ?
`, limit, offset).Scan(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to get pending appeals: %w", err)
}
result := make([]StrikeInfo, len(rows))
for i, r := range rows {
result[i] = StrikeInfo{
ID: r.ID.String(),
Reason: r.Reason,
Severity: r.Severity,
IsActive: r.IsActive,
Appealed: r.Appealed,
CreatedAt: r.CreatedAt,
}
}
return result, total, nil
}
// --- Internal helpers ---
func (s *ModerationService) hideContent(tx *gorm.DB, contentType string, contentID uuid.UUID) {
switch contentType {
case "track":
tx.Exec("UPDATE tracks SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL", contentID)
case "comment":
tx.Exec("UPDATE comments SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL", contentID)
}
}
func (s *ModerationService) issueStrike(tx *gorm.DB, userID, issuedBy, reportID uuid.UUID, reason, severity string) {
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
// SECURITY(HIGH-010): Prevent moderator from issuing a strike against themselves
if userID == issuedBy {
s.logger.Warn("Blocked self-strike attempt",
zap.String("moderator_id", issuedBy.String()),
zap.String("report_id", reportID.String()))
return
}
tx.Exec(`
INSERT INTO user_strikes (user_id, report_id, reason, severity, issued_by)
VALUES (?, ?, ?, ?, ?)
`, userID, reportID, reason, severity, issuedBy)
// Check for 3-strike auto-suspension
var activeCount int64
tx.Raw("SELECT COUNT(*) FROM user_strikes WHERE user_id = ? AND is_active = TRUE", userID).Scan(&activeCount)
if activeCount >= 3 {
s.suspendUser(tx, userID, issuedBy, "Automatic suspension: 3 active strikes reached", nil)
}
}
func (s *ModerationService) suspendUser(tx *gorm.DB, userID, suspendedBy uuid.UUID, reason string, durationDays *int) {
var suspendedUntil *time.Time
if durationDays != nil {
t := time.Now().AddDate(0, 0, *durationDays)
suspendedUntil = &t
}
tx.Exec(`
INSERT INTO user_suspensions (user_id, reason, suspended_by, suspended_until)
VALUES (?, ?, ?, ?)
`, userID, reason, suspendedBy, suspendedUntil)
// Mark user as banned
tx.Exec("UPDATE users SET is_banned = TRUE WHERE id = ?", userID)
}
// DB returns the underlying database for advanced queries in handlers
func (s *ModerationService) DB() *gorm.DB {
return s.db
}
// GetModerationStats returns moderation overview stats
func (s *ModerationService) GetModerationStats(ctx context.Context) (map[string]interface{}, error) {
stats := map[string]interface{}{}
var pendingReports, resolvedReports, pendingAppeals, pendingFingerprints int64
s.db.WithContext(ctx).Table("reports").Where("status = 'pending'").Count(&pendingReports)
s.db.WithContext(ctx).Table("reports").Where("status IN ('resolved', 'dismissed')").Count(&resolvedReports)
s.db.WithContext(ctx).Table("user_strikes").Where("appealed = TRUE AND appeal_resolved = FALSE").Count(&pendingAppeals)
s.db.WithContext(ctx).Table("audio_fingerprints").Where("status = 'matched' AND reviewed = FALSE").Count(&pendingFingerprints)
stats["pending_reports"] = pendingReports
stats["resolved_reports"] = resolvedReports
stats["pending_appeals"] = pendingAppeals
stats["pending_fingerprints"] = pendingFingerprints
return stats, nil
}