441 lines
16 KiB
Go
441 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
type ModerationReportStore struct {
|
|
db sqlcgen.DBTX
|
|
}
|
|
|
|
func NewModerationReportStore(db sqlcgen.DBTX) *ModerationReportStore {
|
|
return &ModerationReportStore{db: db}
|
|
}
|
|
|
|
func (s *ModerationReportStore) CreateModerationReport(ctx context.Context, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
|
}
|
|
if err := report.Validate(); err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
beginner, ok := s.db.(txBeginner)
|
|
if !ok {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
|
|
}
|
|
tx, err := beginner.Begin(ctx)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("begin moderation report: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
stored, created, err := createModerationReportTx(ctx, tx, report)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("commit moderation report: %w", err)
|
|
}
|
|
return stored, created, nil
|
|
}
|
|
|
|
func createModerationReportTx(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
|
var (
|
|
reportID int64
|
|
err error
|
|
)
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended('moderation-report:' || $1::bigint::text, 0)
|
|
)`, report.ReporterUserID); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
|
|
}
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM moderation_reports
|
|
WHERE reporter_user_id = $1 AND fingerprint = $2`,
|
|
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID)
|
|
if err == nil {
|
|
existing, found, err := getModerationReport(ctx, tx, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if !found {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
|
|
}
|
|
return existing, false, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("lookup moderation report fingerprint: %w", err)
|
|
}
|
|
var hourly, daily int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT
|
|
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
|
|
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
|
|
FROM moderation_reports
|
|
WHERE reporter_user_id = $1
|
|
AND created_at <= $2::timestamptz`,
|
|
report.ReporterUserID, report.CreatedAt).Scan(&hourly, &daily); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("count moderation reporter submissions: %w", err)
|
|
}
|
|
if hourly >= domain.MaxModerationReportsPerHour || daily >= domain.MaxModerationReportsPerDay {
|
|
return domain.ModerationReport{}, false, domain.ErrModerationRateLimited
|
|
}
|
|
reportID, created, err := insertModerationReport(ctx, tx, report)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if !created {
|
|
existing, found, err := getModerationReport(ctx, tx, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if !found {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
|
|
}
|
|
return existing, false, nil
|
|
}
|
|
report.ID = reportID
|
|
return domain.CloneModerationReport(report), true, nil
|
|
}
|
|
|
|
func (s *ModerationReportStore) ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
|
}
|
|
if legacyReportID <= 0 {
|
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
|
}
|
|
if err := report.Validate(); err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if report.Source != domain.ModerationSourceEphemeral {
|
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
|
}
|
|
beginner, ok := s.db.(txBeginner)
|
|
if !ok {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
|
|
}
|
|
tx, err := beginner.Begin(ctx)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("begin legacy ephemeral report import: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended('moderation-legacy-ephemeral:' || $1::bigint::text, 0)
|
|
)`, legacyReportID); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("lock legacy ephemeral report: %w", err)
|
|
}
|
|
var reportID int64
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT moderation_report_id
|
|
FROM moderation_legacy_ephemeral_migrations
|
|
WHERE legacy_report_id = $1`, legacyReportID).Scan(&reportID)
|
|
if err == nil {
|
|
existing, found, err := getModerationReport(ctx, tx, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if !found {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("legacy ephemeral report mapping points to missing moderation report")
|
|
}
|
|
return existing, false, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("lookup legacy ephemeral report mapping: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended('moderation-report:' || $1::bigint::text, 0)
|
|
)`, report.ReporterUserID); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
|
|
}
|
|
reportID, created, err := insertModerationReport(ctx, tx, report)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO moderation_legacy_ephemeral_migrations (
|
|
legacy_report_id, moderation_report_id, migrated_at
|
|
) VALUES ($1,$2,clock_timestamp())`,
|
|
legacyReportID, reportID,
|
|
); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("insert legacy ephemeral report mapping: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("commit legacy ephemeral report import: %w", err)
|
|
}
|
|
if created {
|
|
report.ID = reportID
|
|
return domain.CloneModerationReport(report), true, nil
|
|
}
|
|
existing, found, err := getModerationReport(ctx, s.db, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, err
|
|
}
|
|
if !found {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("imported duplicate moderation report disappeared")
|
|
}
|
|
return existing, false, nil
|
|
}
|
|
|
|
func insertModerationReport(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (int64, bool, error) {
|
|
var reportID int64
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO moderation_reports (
|
|
reporter_user_id, source, target_peer_type, target_peer_id, reason,
|
|
report_option, report_comment, comment_hash, fingerprint,
|
|
taxonomy_version, created_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
ON CONFLICT (reporter_user_id, fingerprint) DO NOTHING
|
|
RETURNING id`,
|
|
report.ReporterUserID, string(report.Source), string(report.Target.Type),
|
|
report.Target.ID, string(report.Reason), report.Option, report.Comment,
|
|
report.CommentHash[:], report.Fingerprint[:], report.TaxonomyVersion,
|
|
report.CreatedAt,
|
|
).Scan(&reportID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM moderation_reports
|
|
WHERE reporter_user_id = $1 AND fingerprint = $2`,
|
|
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID); err != nil {
|
|
return 0, false, fmt.Errorf("lookup duplicate moderation report: %w", err)
|
|
}
|
|
return reportID, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("insert moderation report: %w", err)
|
|
}
|
|
for ordinal, item := range report.Items {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO moderation_report_items (
|
|
report_id, ordinal, item_kind, peer_type, peer_id, item_id,
|
|
secondary_id, author_user_id, evidence_schema_version, evidence,
|
|
evidence_hash
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11)`,
|
|
reportID, ordinal, string(item.Kind), string(item.Peer.Type),
|
|
item.Peer.ID, item.ItemID, item.SecondaryID, item.AuthorUserID,
|
|
item.EvidenceSchemaVersion, []byte(item.Evidence), item.EvidenceHash[:],
|
|
); err != nil {
|
|
return 0, false, fmt.Errorf("insert moderation report item %d: %w", ordinal, err)
|
|
}
|
|
}
|
|
for _, hold := range report.MediaHolds {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO moderation_media_holds (
|
|
report_id, item_ordinal, media_kind, storage_key, created_at
|
|
) VALUES ($1,$2,$3,$4,$5)`,
|
|
reportID, hold.ItemIndex, string(hold.Kind), hold.StorageKey,
|
|
report.CreatedAt,
|
|
); err != nil {
|
|
return 0, false, fmt.Errorf("insert moderation media hold: %w", err)
|
|
}
|
|
}
|
|
if err := attachModerationReportToCase(ctx, tx, reportID, report); err != nil {
|
|
return 0, false, err
|
|
}
|
|
return reportID, true, nil
|
|
}
|
|
|
|
func attachModerationReportToCase(ctx context.Context, tx pgx.Tx, reportID int64, report domain.ModerationReport) error {
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended(
|
|
'moderation-case:' || $1::text || ':' || $2::bigint::text,
|
|
0
|
|
)
|
|
)`, string(report.Target.Type), report.Target.ID); err != nil {
|
|
return fmt.Errorf("lock moderation case target: %w", err)
|
|
}
|
|
var caseID int64
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM moderation_cases
|
|
WHERE target_peer_type = $1
|
|
AND target_peer_id = $2
|
|
AND status IN ('open', 'in_review')
|
|
FOR UPDATE`,
|
|
string(report.Target.Type), report.Target.ID,
|
|
).Scan(&caseID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO moderation_cases (
|
|
target_peer_type, target_peer_id, status, severity, assigned_to,
|
|
version, report_count, distinct_reporter_count, first_report_at,
|
|
last_report_at, created_at, updated_at
|
|
) VALUES ($1,$2,'open',$3,'',1,1,1,$4,$4,$4,$4)
|
|
RETURNING id`,
|
|
string(report.Target.Type), report.Target.ID,
|
|
int16(domain.ModerationSeverityForReason(report.Reason)),
|
|
report.CreatedAt,
|
|
).Scan(&caseID)
|
|
if err != nil {
|
|
return fmt.Errorf("create moderation case: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
|
|
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
|
|
return fmt.Errorf("attach report to new moderation case: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("find active moderation case: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
|
|
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
|
|
return fmt.Errorf("attach report to moderation case: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE moderation_cases c
|
|
SET severity = greatest(c.severity, $2),
|
|
version = c.version + 1,
|
|
report_count = (
|
|
SELECT count(*)::integer
|
|
FROM moderation_case_reports cr
|
|
WHERE cr.case_id = c.id
|
|
),
|
|
distinct_reporter_count = (
|
|
SELECT count(DISTINCT r.reporter_user_id)::integer
|
|
FROM moderation_case_reports cr
|
|
JOIN moderation_reports r ON r.id = cr.report_id
|
|
WHERE cr.case_id = c.id
|
|
),
|
|
first_report_at = least(c.first_report_at, $3),
|
|
last_report_at = greatest(c.last_report_at, $3),
|
|
updated_at = greatest(c.updated_at, $3)
|
|
WHERE c.id = $1`,
|
|
caseID, int16(domain.ModerationSeverityForReason(report.Reason)),
|
|
report.CreatedAt,
|
|
); err != nil {
|
|
return fmt.Errorf("update moderation case aggregates: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ModerationReportStore) GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
|
}
|
|
if reportID <= 0 {
|
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
|
}
|
|
return getModerationReport(ctx, s.db, reportID)
|
|
}
|
|
|
|
func getModerationReport(ctx context.Context, db sqlcgen.DBTX, reportID int64) (domain.ModerationReport, bool, error) {
|
|
var (
|
|
report domain.ModerationReport
|
|
source, target, reason string
|
|
commentHash []byte
|
|
fingerprint []byte
|
|
)
|
|
err := db.QueryRow(ctx, `
|
|
SELECT id, reporter_user_id, source, target_peer_type, target_peer_id,
|
|
reason, report_option, report_comment, comment_hash, fingerprint,
|
|
taxonomy_version, created_at
|
|
FROM moderation_reports
|
|
WHERE id = $1`, reportID).Scan(
|
|
&report.ID, &report.ReporterUserID, &source, &target,
|
|
&report.Target.ID, &reason, &report.Option, &report.Comment,
|
|
&commentHash, &fingerprint, &report.TaxonomyVersion,
|
|
&report.CreatedAt,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ModerationReport{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: %w", err)
|
|
}
|
|
if len(commentHash) != len(report.CommentHash) || len(fingerprint) != len(report.Fingerprint) {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: invalid persisted hash length")
|
|
}
|
|
copy(report.CommentHash[:], commentHash)
|
|
copy(report.Fingerprint[:], fingerprint)
|
|
report.Source = domain.ModerationReportSource(source)
|
|
report.Target.Type = domain.PeerType(target)
|
|
report.Reason = domain.ModerationReason(reason)
|
|
|
|
rows, err := db.Query(ctx, `
|
|
SELECT ordinal, item_kind, peer_type, peer_id, item_id, secondary_id,
|
|
author_user_id, evidence_schema_version, evidence, evidence_hash
|
|
FROM moderation_report_items
|
|
WHERE report_id = $1
|
|
ORDER BY ordinal`, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("list moderation report items: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var (
|
|
ordinal int
|
|
item domain.ModerationReportItem
|
|
kind, peerType string
|
|
evidence, evidenceHash []byte
|
|
)
|
|
if err := rows.Scan(
|
|
&ordinal, &kind, &peerType, &item.Peer.ID, &item.ItemID,
|
|
&item.SecondaryID, &item.AuthorUserID,
|
|
&item.EvidenceSchemaVersion, &evidence, &evidenceHash,
|
|
); err != nil {
|
|
rows.Close()
|
|
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: %w", err)
|
|
}
|
|
if ordinal != len(report.Items) || len(evidenceHash) != len(item.EvidenceHash) {
|
|
rows.Close()
|
|
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: invalid persisted ordering or hash")
|
|
}
|
|
canonical, err := domain.CanonicalModerationEvidence(evidence)
|
|
if err != nil {
|
|
rows.Close()
|
|
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item evidence: %w", err)
|
|
}
|
|
item.Kind = domain.ModerationReportItemKind(kind)
|
|
item.Peer.Type = domain.PeerType(peerType)
|
|
item.Evidence = canonical
|
|
copy(item.EvidenceHash[:], evidenceHash)
|
|
report.Items = append(report.Items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation report items: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
holdRows, err := db.Query(ctx, `
|
|
SELECT item_ordinal, media_kind, storage_key
|
|
FROM moderation_media_holds
|
|
WHERE report_id = $1
|
|
ORDER BY item_ordinal, media_kind, storage_key`, reportID)
|
|
if err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("list moderation media holds: %w", err)
|
|
}
|
|
defer holdRows.Close()
|
|
for holdRows.Next() {
|
|
var hold domain.ModerationMediaHold
|
|
var kind string
|
|
if err := holdRows.Scan(&hold.ItemIndex, &kind, &hold.StorageKey); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation media hold: %w", err)
|
|
}
|
|
hold.Kind = domain.ModerationMediaKind(kind)
|
|
report.MediaHolds = append(report.MediaHolds, hold)
|
|
}
|
|
if err := holdRows.Err(); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation media holds: %w", err)
|
|
}
|
|
if err := report.Validate(); err != nil {
|
|
return domain.ModerationReport{}, false, fmt.Errorf("validate persisted moderation report: %w", err)
|
|
}
|
|
return report, true, nil
|
|
}
|