diff --git a/REPORTS.md b/REPORTS.md new file mode 100644 index 0000000..cac4a41 --- /dev/null +++ b/REPORTS.md @@ -0,0 +1,114 @@ +# NIP-56 Reports Implementation + +This relay implements [NIP-56 (Reporting)](https://nips.nostr.com/) for handling content reports and integrates them with the [NIP-86 Management API](https://nips.nostr.com/86). + +## Features + +### Report Storage +- Automatically stores all kind `1984` events (reports) to a dedicated database table +- Extracts report metadata: + - Reporter pubkey + - Reported pubkey (from `p` tag) + - Reported event ID (from `e` tag, optional) + - Report type (nudity, malware, profanity, illegal, spam, impersonation, other) + - Report content/reason + +### Report Management via NIP-86 + +The following NIP-86 management methods are available: + +#### `listevents needingmoderation` +Returns all unresolved reports showing: +- Event ID or pubkey that was reported +- Report type, content, and reporter information +- Only shows reports that haven't been resolved yet + +#### `allowevent` +- Marks all reports about the specified event/pubkey as resolved +- Adds the event to the `allowed_events` table +- Useful for dismissing false reports + +#### `banevent` +- Marks all reports about the specified event/pubkey as resolved +- Adds the event to the `banned_events` table +- Deletes the actual event from the relay (if it exists) +- Prevents the event from being accepted in the future + +#### `listbannedevents` +Returns all events that have been banned, with reasons + +#### `listaLlowedevents` +Returns all events that have been explicitly allowed (reports dismissed) + +### Event Rejection +The relay automatically rejects: +- Events from banned pubkeys +- Events with IDs in the banned events list + +## Database Schema + +### `reports` table +```sql +CREATE TABLE reports ( + id TEXT PRIMARY KEY, -- Report event ID + reporter_pubkey TEXT NOT NULL, -- Who submitted the report + reported_event_id TEXT, -- Event being reported (optional) + reported_pubkey TEXT NOT NULL, -- Pubkey being reported + report_type TEXT NOT NULL, -- nudity, malware, profanity, etc. + content TEXT, -- Additional details from reporter + resolved BOOLEAN DEFAULT FALSE, -- Whether report has been handled + resolution TEXT, -- How it was resolved + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### `banned_events` table +```sql +CREATE TABLE banned_events ( + event_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### `allowed_events` table +```sql +CREATE TABLE allowed_events ( + event_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +) +``` + +## Usage Example + +### Viewing Reports +Use a NIP-86 compatible client to call: +``` +listeventsNeedingModeration +``` + +### Handling a Report +Ban a reported event: +``` +banEvent("event_id_here", "confirmed spam") +``` + +Or dismiss a false report: +``` +allowEvent("event_id_here", "false report - content is acceptable") +``` + +## Report Types (NIP-56) + +- `nudity` - depictions of nudity, porn, etc. +- `malware` - virus, trojan horse, worm, etc. +- `profanity` - profanity, hateful speech, etc. +- `illegal` - something which may be illegal in some jurisdiction +- `spam` - spam +- `impersonation` - someone pretending to be someone else +- `other` - for reports that don't fit in the above categories + +## Authentication + +All NIP-86 management endpoints require authentication with the relay owner's pubkey (configured via `RELAY_PUBKEY` environment variable). diff --git a/main.go b/main.go index ec95190..07683fc 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,48 @@ func initManagementDB(db *sql.DB) error { return fmt.Errorf("failed to create banned_pubkeys table: %w", err) } + // create reports table for NIP-56 + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + reporter_pubkey TEXT NOT NULL, + reported_event_id TEXT, + reported_pubkey TEXT NOT NULL, + report_type TEXT NOT NULL, + content TEXT, + resolved BOOLEAN NOT NULL DEFAULT FALSE, + resolution TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `) + if err != nil { + return fmt.Errorf("failed to create reports table: %w", err) + } + + // create banned_events table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS banned_events ( + event_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `) + if err != nil { + return fmt.Errorf("failed to create banned_events table: %w", err) + } + + // create allowed_events table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS allowed_events ( + event_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `) + if err != nil { + return fmt.Errorf("failed to create allowed_events table: %w", err) + } + return nil } @@ -92,6 +134,42 @@ func main() { panic(err) } + // Store NIP-56 report events + relay.StoreEvent = append(relay.StoreEvent, func(ctx context.Context, event *nostr.Event) error { + if event.Kind == 1984 { + // Parse report tags + var reportedEventId, reportedPubkey, reportType string + + // Extract reported pubkey and type from p tag + if pTag := event.Tags.Find("p"); len(pTag) >= 2 { + reportedPubkey = pTag[1] + if len(pTag) >= 3 { + reportType = pTag[2] + } + } + + // Extract reported event id and type from e tag + if eTag := event.Tags.Find("e"); len(eTag) >= 2 { + reportedEventId = eTag[1] + if len(eTag) >= 3 && reportType == "" { + reportType = eTag[2] + } + } + + if reportedPubkey != "" && reportType != "" { + _, err := managementDB.ExecContext(ctx, ` + INSERT INTO reports (id, reporter_pubkey, reported_event_id, reported_pubkey, report_type, content, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING + `, event.ID, event.PubKey, reportedEventId, reportedPubkey, reportType, event.Content, time.Unix(int64(event.CreatedAt), 0)) + if err != nil { + return fmt.Errorf("failed to store report: %w", err) + } + } + } + return nil + }) + relay.RejectEvent = append(relay.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) { var reason string @@ -108,6 +186,23 @@ func main() { }, ) + // Check for banned events + relay.RejectEvent = append(relay.RejectEvent, + func(ctx context.Context, event *nostr.Event) (reject bool, msg string) { + var reason string + row := managementDB.QueryRowContext(ctx, `SELECT reason FROM banned_events WHERE event_id = $1`, event.ID) + switch err := row.Scan(&reason); err { + case sql.ErrNoRows: + return false, "" + case nil: + return true, fmt.Sprintf("event %s banned: %s", event.ID, reason) + default: + // on unexpected DB errors, do not reject the event solely because of the failure + return false, "" + } + }, + ) + // // there are many other configurable things you can set // relay.RejectEvent = append(relay.RejectEvent, // // built-in policies @@ -190,6 +285,127 @@ func main() { return result, rows.Err() } + relay.ManagementAPI.ListEventsNeedingModeration = func(ctx context.Context) ([]nip86.IDReason, error) { + rows, err := managementDB.Query(` + SELECT COALESCE(reported_event_id, reported_pubkey), + CONCAT(report_type, ': ', content, ' (reported by ', reporter_pubkey, ')') + FROM reports + WHERE resolved = FALSE + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []nip86.IDReason + for rows.Next() { + var ir nip86.IDReason + if err := rows.Scan(&ir.ID, &ir.Reason); err != nil { + return nil, err + } + result = append(result, ir) + } + return result, rows.Err() + } + + relay.ManagementAPI.AllowEvent = func(ctx context.Context, id string, reason string) error { + // Mark reports about this event as resolved + _, err := managementDB.Exec(` + UPDATE reports + SET resolved = TRUE, resolution = $2 + WHERE reported_event_id = $1 OR reported_pubkey = $1 + `, id, "allowed: "+reason) + if err != nil { + return err + } + + // Add to allowed_events table + _, err = managementDB.Exec(` + INSERT INTO allowed_events (event_id, reason, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (event_id) DO UPDATE SET reason = $2, created_at = $3 + `, id, reason, time.Now()) + return err + } + + relay.ManagementAPI.BanEvent = func(ctx context.Context, id string, reason string) error { + // Mark reports about this event as resolved + _, err := managementDB.Exec(` + UPDATE reports + SET resolved = TRUE, resolution = $2 + WHERE reported_event_id = $1 OR reported_pubkey = $1 + `, id, "banned: "+reason) + if err != nil { + return err + } + + // Add to banned_events table + _, err = managementDB.Exec(` + INSERT INTO banned_events (event_id, reason, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (event_id) DO UPDATE SET reason = $2, created_at = $3 + `, id, reason, time.Now()) + if err != nil { + return err + } + + // Query and delete the event from the main event store + for _, query := range relay.QueryEvents { + ch, err := query(ctx, nostr.Filter{IDs: []string{id}}) + if err != nil { + continue + } + evt := <-ch + if evt != nil { + for _, deleter := range relay.DeleteEvent { + if err := deleter(ctx, evt); err != nil { + return fmt.Errorf("failed to delete event: %w", err) + } + } + break + } + } + + return nil + } + + relay.ManagementAPI.ListBannedEvents = func(ctx context.Context) ([]nip86.IDReason, error) { + rows, err := managementDB.Query(`SELECT event_id, reason FROM banned_events ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []nip86.IDReason + for rows.Next() { + var ir nip86.IDReason + if err := rows.Scan(&ir.ID, &ir.Reason); err != nil { + return nil, err + } + result = append(result, ir) + } + return result, rows.Err() + } + + relay.ManagementAPI.ListAllowedEvents = func(ctx context.Context) ([]nip86.IDReason, error) { + rows, err := managementDB.Query(`SELECT event_id, reason FROM allowed_events ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []nip86.IDReason + for rows.Next() { + var ir nip86.IDReason + if err := rows.Scan(&ir.ID, &ir.Reason); err != nil { + return nil, err + } + result = append(result, ir) + } + return result, rows.Err() + } + mux := relay.Router() // set up other http handlers mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {