You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
593 lines
16 KiB
593 lines
16 KiB
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 |
|
}
|
|
|