Files
next.orly.dev/app/handle-nip86-curating.go
woikos 047cdf3472
Some checks failed
Go / build-and-release (push) Has been cancelled
Add curation ACL mode and complete graph query implementation (v0.47.0)
Curation Mode:
- Three-tier publisher classification: Trusted, Blacklisted, Unclassified
- Per-pubkey rate limiting (default 50/day) for unclassified users
- IP flood protection (default 500/day) with automatic banning
- Event kind allow-listing via categories, ranges, and custom kinds
- Query filtering hides blacklisted pubkey events (admin/owner exempt)
- Web UI for managing trusted/blacklisted pubkeys and configuration
- NIP-86 API endpoints for all curation management operations

Graph Query Extension:
- Complete reference aggregation for Badger and Neo4j backends
- E-tag graph backfill migration (v8) runs automatically on startup
- Configuration options: ORLY_GRAPH_QUERIES_ENABLED, MAX_DEPTH, etc.
- NIP-11 advertisement of graph query capabilities

Files modified:
- app/handle-nip86-curating.go: NIP-86 curation API handlers (new)
- app/web/src/CurationView.svelte: Curation management UI (new)
- app/web/src/kindCategories.js: Kind category definitions (new)
- pkg/acl/curating.go: Curating ACL implementation (new)
- pkg/database/curating-acl.go: Database layer for curation (new)
- pkg/neo4j/graph-refs.go: Neo4j ref collection (new)
- pkg/database/migrations.go: E-tag graph backfill migration
- pkg/protocol/graph/executor.go: Reference aggregation support
- app/handle-event.go: Curation config event processing
- app/handle-req.go: Blacklist filtering for queries
- docs/GRAPH_QUERIES_REMAINING_PLAN.md: Updated completion status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:42:17 +01:00

594 lines
16 KiB
Go

package app
import (
"context"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/httpauth"
)
// handleCuratingNIP86Request handles curating NIP-86 requests with pre-authenticated pubkey.
// This is called from the main NIP-86 handler after authentication.
func (s *Server) handleCuratingNIP86Request(w http.ResponseWriter, r *http.Request, pubkey []byte) {
_ = pubkey // Pubkey already validated by caller
// Get the curating ACL instance
var curatingACL *acl.Curating
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "curating" {
if curating, ok := aclInstance.(*acl.Curating); ok {
curatingACL = curating
break
}
}
}
if curatingACL == nil {
http.Error(w, "Curating ACL not available", http.StatusInternalServerError)
return
}
// Read and parse the request
body, err := io.ReadAll(r.Body)
if chk.E(err) {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var request NIP86Request
if err := json.Unmarshal(body, &request); chk.E(err) {
http.Error(w, "Invalid JSON request", http.StatusBadRequest)
return
}
// Set response headers
w.Header().Set("Content-Type", "application/json")
// Handle the request based on method
response := s.handleCuratingNIP86Method(request, curatingACL)
// Send response
jsonData, err := json.Marshal(response)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}
// handleCuratingNIP86Management handles NIP-86 management API requests for curating mode (standalone)
func (s *Server) handleCuratingNIP86Management(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check Content-Type
contentType := r.Header.Get("Content-Type")
if contentType != "application/nostr+json+rpc" {
http.Error(w, "Content-Type must be application/nostr+json+rpc", http.StatusBadRequest)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner or admin level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" && accessLevel != "admin" {
http.Error(w, "Owner or admin permission required", http.StatusForbidden)
return
}
// Check if curating ACL is active
if acl.Registry.Type() != "curating" {
http.Error(w, "Curating ACL mode is not active", http.StatusBadRequest)
return
}
// Delegate to shared request handler
s.handleCuratingNIP86Request(w, r, pubkey)
}
// handleCuratingNIP86Method handles individual NIP-86 methods for curating mode
func (s *Server) handleCuratingNIP86Method(request NIP86Request, curatingACL *acl.Curating) NIP86Response {
dbACL := curatingACL.GetCuratingACL()
switch request.Method {
case "supportedmethods":
return s.handleCuratingSupportedMethods()
case "trustpubkey":
return s.handleTrustPubkey(request.Params, curatingACL)
case "untrustpubkey":
return s.handleUntrustPubkey(request.Params, curatingACL)
case "listtrustedpubkeys":
return s.handleListTrustedPubkeys(dbACL)
case "blacklistpubkey":
return s.handleBlacklistPubkey(request.Params, curatingACL)
case "unblacklistpubkey":
return s.handleUnblacklistPubkey(request.Params, curatingACL)
case "listblacklistedpubkeys":
return s.handleListBlacklistedPubkeys(dbACL)
case "listunclassifiedusers":
return s.handleListUnclassifiedUsers(request.Params, dbACL)
case "markspam":
return s.handleMarkSpam(request.Params, dbACL)
case "unmarkspam":
return s.handleUnmarkSpam(request.Params, dbACL)
case "listspamevents":
return s.handleListSpamEvents(dbACL)
case "deleteevent":
return s.handleDeleteEvent(request.Params)
case "getcuratingconfig":
return s.handleGetCuratingConfig(dbACL)
case "listblockedips":
return s.handleListCuratingBlockedIPs(dbACL)
case "unblockip":
return s.handleUnblockCuratingIP(request.Params, dbACL)
case "isconfigured":
return s.handleIsConfigured(dbACL)
default:
return NIP86Response{Error: "Unknown method: " + request.Method}
}
}
// handleCuratingSupportedMethods returns the list of supported methods for curating mode
func (s *Server) handleCuratingSupportedMethods() NIP86Response {
methods := []string{
"supportedmethods",
"trustpubkey",
"untrustpubkey",
"listtrustedpubkeys",
"blacklistpubkey",
"unblacklistpubkey",
"listblacklistedpubkeys",
"listunclassifiedusers",
"markspam",
"unmarkspam",
"listspamevents",
"deleteevent",
"getcuratingconfig",
"listblockedips",
"unblockip",
"isconfigured",
}
return NIP86Response{Result: methods}
}
// handleTrustPubkey adds a pubkey to the trusted list
func (s *Server) handleTrustPubkey(params []interface{}, curatingACL *acl.Curating) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
note := ""
if len(params) > 1 {
if n, ok := params[1].(string); ok {
note = n
}
}
if err := curatingACL.TrustPubkey(pubkey, note); chk.E(err) {
return NIP86Response{Error: "Failed to trust pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleUntrustPubkey removes a pubkey from the trusted list
func (s *Server) handleUntrustPubkey(params []interface{}, curatingACL *acl.Curating) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if err := curatingACL.UntrustPubkey(pubkey); chk.E(err) {
return NIP86Response{Error: "Failed to untrust pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListTrustedPubkeys returns the list of trusted pubkeys
func (s *Server) handleListTrustedPubkeys(dbACL *database.CuratingACL) NIP86Response {
trusted, err := dbACL.ListTrustedPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to list trusted pubkeys: " + err.Error()}
}
result := make([]map[string]interface{}, len(trusted))
for i, t := range trusted {
result[i] = map[string]interface{}{
"pubkey": t.Pubkey,
"note": t.Note,
"added": t.Added.Unix(),
}
}
return NIP86Response{Result: result}
}
// handleBlacklistPubkey adds a pubkey to the blacklist
func (s *Server) handleBlacklistPubkey(params []interface{}, curatingACL *acl.Curating) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := curatingACL.BlacklistPubkey(pubkey, reason); chk.E(err) {
return NIP86Response{Error: "Failed to blacklist pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleUnblacklistPubkey removes a pubkey from the blacklist
func (s *Server) handleUnblacklistPubkey(params []interface{}, curatingACL *acl.Curating) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if err := curatingACL.UnblacklistPubkey(pubkey); chk.E(err) {
return NIP86Response{Error: "Failed to unblacklist pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListBlacklistedPubkeys returns the list of blacklisted pubkeys
func (s *Server) handleListBlacklistedPubkeys(dbACL *database.CuratingACL) NIP86Response {
blacklisted, err := dbACL.ListBlacklistedPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to list blacklisted pubkeys: " + err.Error()}
}
result := make([]map[string]interface{}, len(blacklisted))
for i, b := range blacklisted {
result[i] = map[string]interface{}{
"pubkey": b.Pubkey,
"reason": b.Reason,
"added": b.Added.Unix(),
}
}
return NIP86Response{Result: result}
}
// handleListUnclassifiedUsers returns unclassified users sorted by event count
func (s *Server) handleListUnclassifiedUsers(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
limit := 100 // Default limit
if len(params) > 0 {
if l, ok := params[0].(float64); ok {
limit = int(l)
}
}
users, err := dbACL.ListUnclassifiedUsers(limit)
if chk.E(err) {
return NIP86Response{Error: "Failed to list unclassified users: " + err.Error()}
}
result := make([]map[string]interface{}, len(users))
for i, u := range users {
result[i] = map[string]interface{}{
"pubkey": u.Pubkey,
"event_count": u.EventCount,
"last_event": u.LastEvent.Unix(),
}
}
return NIP86Response{Result: result}
}
// handleMarkSpam marks an event as spam
func (s *Server) handleMarkSpam(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: event_id"}
}
eventID, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid event_id parameter"}
}
if len(eventID) != 64 {
return NIP86Response{Error: "Invalid event_id format (must be 64 hex characters)"}
}
pubkey := ""
if len(params) > 1 {
if p, ok := params[1].(string); ok {
pubkey = p
}
}
reason := ""
if len(params) > 2 {
if r, ok := params[2].(string); ok {
reason = r
}
}
if err := dbACL.MarkEventAsSpam(eventID, pubkey, reason); chk.E(err) {
return NIP86Response{Error: "Failed to mark event as spam: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleUnmarkSpam removes the spam flag from an event
func (s *Server) handleUnmarkSpam(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: event_id"}
}
eventID, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid event_id parameter"}
}
if err := dbACL.UnmarkEventAsSpam(eventID); chk.E(err) {
return NIP86Response{Error: "Failed to unmark event as spam: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListSpamEvents returns the list of spam-flagged events
func (s *Server) handleListSpamEvents(dbACL *database.CuratingACL) NIP86Response {
spam, err := dbACL.ListSpamEvents()
if chk.E(err) {
return NIP86Response{Error: "Failed to list spam events: " + err.Error()}
}
result := make([]map[string]interface{}, len(spam))
for i, sp := range spam {
result[i] = map[string]interface{}{
"event_id": sp.EventID,
"pubkey": sp.Pubkey,
"reason": sp.Reason,
"added": sp.Added.Unix(),
}
}
return NIP86Response{Result: result}
}
// handleDeleteEvent permanently deletes an event from the database
func (s *Server) handleDeleteEvent(params []interface{}) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: event_id"}
}
eventIDHex, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid event_id parameter"}
}
if len(eventIDHex) != 64 {
return NIP86Response{Error: "Invalid event_id format (must be 64 hex characters)"}
}
// Convert hex to bytes
eventID, err := hex.DecodeString(eventIDHex)
if err != nil {
return NIP86Response{Error: "Invalid event_id hex: " + err.Error()}
}
// Delete from database
if err := s.DB.DeleteEvent(context.Background(), eventID); chk.E(err) {
return NIP86Response{Error: "Failed to delete event: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleGetCuratingConfig returns the current curating configuration
func (s *Server) handleGetCuratingConfig(dbACL *database.CuratingACL) NIP86Response {
config, err := dbACL.GetConfig()
if chk.E(err) {
return NIP86Response{Error: "Failed to get config: " + err.Error()}
}
result := map[string]interface{}{
"daily_limit": config.DailyLimit,
"first_ban_hours": config.FirstBanHours,
"second_ban_hours": config.SecondBanHours,
"allowed_kinds": config.AllowedKinds,
"allowed_ranges": config.AllowedRanges,
"kind_categories": config.KindCategories,
"config_event_id": config.ConfigEventID,
"config_pubkey": config.ConfigPubkey,
"configured_at": config.ConfiguredAt,
"is_configured": config.ConfigEventID != "",
}
return NIP86Response{Result: result}
}
// handleListCuratingBlockedIPs returns the list of blocked IPs in curating mode
func (s *Server) handleListCuratingBlockedIPs(dbACL *database.CuratingACL) NIP86Response {
blocked, err := dbACL.ListBlockedIPs()
if chk.E(err) {
return NIP86Response{Error: "Failed to list blocked IPs: " + err.Error()}
}
result := make([]map[string]interface{}, len(blocked))
for i, b := range blocked {
result[i] = map[string]interface{}{
"ip": b.IP,
"reason": b.Reason,
"expires_at": b.ExpiresAt.Unix(),
"added": b.Added.Unix(),
}
}
return NIP86Response{Result: result}
}
// handleUnblockCuratingIP unblocks an IP in curating mode
func (s *Server) handleUnblockCuratingIP(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: ip"}
}
ip, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid ip parameter"}
}
if err := dbACL.UnblockIP(ip); chk.E(err) {
return NIP86Response{Error: "Failed to unblock IP: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleIsConfigured checks if curating mode is configured
func (s *Server) handleIsConfigured(dbACL *database.CuratingACL) NIP86Response {
configured, err := dbACL.IsConfigured()
if chk.E(err) {
return NIP86Response{Error: "Failed to check configuration: " + err.Error()}
}
return NIP86Response{Result: configured}
}
// GetKindCategoriesInfo returns information about available kind categories
func GetKindCategoriesInfo() []map[string]interface{} {
categories := []map[string]interface{}{
{
"id": "social",
"name": "Social/Notes",
"description": "Profiles, text notes, follows, reposts, reactions",
"kinds": []int{0, 1, 3, 6, 7, 10002},
},
{
"id": "dm",
"name": "Direct Messages",
"description": "NIP-04 DMs, NIP-17 private messages, gift wraps",
"kinds": []int{4, 14, 1059},
},
{
"id": "longform",
"name": "Long-form Content",
"description": "Articles and drafts",
"kinds": []int{30023, 30024},
},
{
"id": "media",
"name": "Media",
"description": "File metadata, video, audio",
"kinds": []int{1063, 20, 21, 22},
},
{
"id": "marketplace",
"name": "Marketplace",
"description": "Product listings, stalls, auctions",
"kinds": []int{30017, 30018, 30019, 30020, 1021, 1022},
},
{
"id": "groups_nip29",
"name": "Group Messaging (NIP-29)",
"description": "Simple group messages and metadata",
"kinds": []int{9, 10, 11, 12, 9000, 9001, 9002, 39000, 39001, 39002},
},
{
"id": "groups_nip72",
"name": "Communities (NIP-72)",
"description": "Moderated communities and post approvals",
"kinds": []int{34550, 1111, 4550},
},
{
"id": "lists",
"name": "Lists/Bookmarks",
"description": "Mute lists, pins, categorized lists, bookmarks",
"kinds": []int{10000, 10001, 10003, 30000, 30001, 30003},
},
}
return categories
}
// expandKindRange expands a range string like "1000-1999" into individual kinds
func expandKindRange(rangeStr string) []int {
var kinds []int
parts := make([]int, 2)
n, err := parseRange(rangeStr, parts)
if err != nil || n != 2 {
return kinds
}
for i := parts[0]; i <= parts[1]; i++ {
kinds = append(kinds, i)
}
return kinds
}
func parseRange(s string, parts []int) (int, error) {
// Simple parsing of "start-end"
for i, c := range s {
if c == '-' && i > 0 {
start, err := strconv.Atoi(s[:i])
if err != nil {
return 0, err
}
end, err := strconv.Atoi(s[i+1:])
if err != nil {
return 0, err
}
parts[0] = start
parts[1] = end
return 2, nil
}
}
return 0, nil
}