Some checks failed
Go / build-and-release (push) Has been cancelled
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>
594 lines
16 KiB
Go
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
|
|
}
|