From 7b253bf8115f8e213e8fd533d1ec961531a51807 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Tue, 13 Jan 2026 15:22:18 +0300 Subject: [PATCH 1/2] feat: relayurl package --- internal/relayurl/implementation.go | 63 ++++ internal/relayurl/implementation_test.go | 409 +++++++++++++++++++++++ internal/relayurl/interface.go | 18 + 3 files changed, 490 insertions(+) create mode 100644 internal/relayurl/implementation.go create mode 100644 internal/relayurl/implementation_test.go create mode 100644 internal/relayurl/interface.go diff --git a/internal/relayurl/implementation.go b/internal/relayurl/implementation.go new file mode 100644 index 0000000..ce6ed80 --- /dev/null +++ b/internal/relayurl/implementation.go @@ -0,0 +1,63 @@ +package relayurl + +import ( + "errors" + "net/url" + "strings" +) + +type relayUrl struct { + u *url.URL +} + +// New parses and validates a relay URL string. +// It trims whitespace, lowercases, removes a trailing slash, and enforces: +// - ws:// or wss:// scheme +// - no query parameters or fragments +// - non-empty host +func New(raw string) (RelayURL, error) { + raw = strings.ToLower(strings.TrimSpace(raw)) + raw = strings.TrimSuffix(raw, "/") + if raw == "" { + return nil, errors.New("empty relay URL") + } + + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, errors.New("relay URL must use ws or wss scheme") + } + if u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("relay URL must not contain query or fragment") + } + if u.Host == "" { + return nil, errors.New("relay URL must have a host") + } + + return relayUrl{ u: u }, nil +} + +func (r relayUrl) String() string { + return r.u.String() +} + +func (r relayUrl) Host() string { + return r.u.Hostname() +} + +func (r relayUrl) Path() string { + return r.u.Path +} + +func (r relayUrl) DTag() string { + s := r.String() + if !strings.HasSuffix(s, "/") { + s += "/" + } + return s +} + + diff --git a/internal/relayurl/implementation_test.go b/internal/relayurl/implementation_test.go new file mode 100644 index 0000000..c6d9fdc --- /dev/null +++ b/internal/relayurl/implementation_test.go @@ -0,0 +1,409 @@ +package relayurl + +import ( + "testing" +) + +func TestNew_ValidURLs(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple ws URL", + input: "ws://relay.example.com", + expected: "ws://relay.example.com", + }, + { + name: "simple wss URL", + input: "wss://relay.example.com", + expected: "wss://relay.example.com", + }, + { + name: "ws URL with port", + input: "ws://relay.example.com:8080", + expected: "ws://relay.example.com:8080", + }, + { + name: "wss URL with port", + input: "wss://relay.example.com:443", + expected: "wss://relay.example.com:443", + }, + { + name: "ws URL with path", + input: "ws://relay.example.com/path", + expected: "ws://relay.example.com/path", + }, + { + name: "wss URL with nested path", + input: "wss://relay.example.com/nested/path", + expected: "wss://relay.example.com/nested/path", + }, + { + name: "ws URL with trailing slash", + input: "ws://relay.example.com/", + expected: "ws://relay.example.com", + }, + { + name: "wss URL with trailing slash and path", + input: "wss://relay.example.com/path/", + expected: "wss://relay.example.com/path", + }, + { + name: "uppercase URL", + input: "WSS://RELAY.EXAMPLE.COM", + expected: "wss://relay.example.com", + }, + { + name: "mixed case URL", + input: "Ws://ReLaY.ExAmPlE.CoM", + expected: "ws://relay.example.com", + }, + { + name: "URL with whitespace", + input: " ws://relay.example.com ", + expected: "ws://relay.example.com", + }, + { + name: "URL with newline", + input: "\nws://relay.example.com\n", + expected: "ws://relay.example.com", + }, + { + name: "localhost ws", + input: "ws://localhost", + expected: "ws://localhost", + }, + { + name: "localhost wss with port", + input: "wss://localhost:8443", + expected: "wss://localhost:8443", + }, + { + name: "IP address ws", + input: "ws://192.168.1.1", + expected: "ws://192.168.1.1", + }, + { + name: "IP address wss with port", + input: "wss://192.168.1.1:443", + expected: "wss://192.168.1.1:443", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + if relay.String() != tt.expected { + t.Errorf("String() = %v, want %v", relay.String(), tt.expected) + } + }) + } +} + +func TestNew_InvalidURLs(t *testing.T) { + tests := []struct { + name string + input string + expectedErr string + }{ + { + name: "empty string", + input: "", + expectedErr: "empty relay URL", + }, + { + name: "whitespace only", + input: " ", + expectedErr: "empty relay URL", + }, + { + name: "http scheme", + input: "http://relay.example.com", + expectedErr: "relay URL must use ws or wss scheme", + }, + { + name: "https scheme", + input: "https://relay.example.com", + expectedErr: "relay URL must use ws or wss scheme", + }, + { + name: "no scheme", + input: "relay.example.com", + expectedErr: "relay URL must use ws or wss scheme", + }, + { + name: "query parameters", + input: "ws://relay.example.com?param=value", + expectedErr: "relay URL must not contain query or fragment", + }, + { + name: "fragment", + input: "ws://relay.example.com#fragment", + expectedErr: "relay URL must not contain query or fragment", + }, + { + name: "query and fragment", + input: "ws://relay.example.com?param=value#fragment", + expectedErr: "relay URL must not contain query or fragment", + }, + { + name: "no host", + input: "ws://", + expectedErr: "relay URL must have a host", + }, + { + name: "no host with path", + input: "ws:///path", + expectedErr: "relay URL must have a host", + }, + { + name: "invalid URL format", + input: "://invalid", + expectedErr: "", // url.Parse will return an error + }, + { + name: "malformed URL", + input: "ws://[invalid", + expectedErr: "", // url.Parse will return an error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err == nil { + t.Errorf("New() error = nil, want error containing %q", tt.expectedErr) + return + } + if relay != nil { + t.Errorf("New() relay = %v, want nil", relay) + } + if tt.expectedErr != "" && err.Error() != tt.expectedErr { + t.Errorf("New() error = %q, want %q", err.Error(), tt.expectedErr) + } + }) + } +} + +func TestRelayURL_String(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple URL", + input: "ws://relay.example.com", + expected: "ws://relay.example.com", + }, + { + name: "URL with port", + input: "wss://relay.example.com:443", + expected: "wss://relay.example.com:443", + }, + { + name: "URL with path", + input: "ws://relay.example.com/path/to/relay", + expected: "ws://relay.example.com/path/to/relay", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := relay.String(); got != tt.expected { + t.Errorf("String() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRelayURL_Host(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple host", + input: "ws://relay.example.com", + expected: "relay.example.com", + }, + { + name: "host with port", + input: "ws://relay.example.com:8080", + expected: "relay.example.com", + }, + { + name: "host with path", + input: "wss://relay.example.com/path", + expected: "relay.example.com", + }, + { + name: "localhost", + input: "ws://localhost:8080", + expected: "localhost", + }, + { + name: "IP address", + input: "wss://192.168.1.1:443", + expected: "192.168.1.1", + }, + { + name: "subdomain", + input: "ws://sub.relay.example.com", + expected: "sub.relay.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := relay.Host(); got != tt.expected { + t.Errorf("Host() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRelayURL_Path(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no path", + input: "ws://relay.example.com", + expected: "", + }, + { + name: "root path", + input: "ws://relay.example.com/", + expected: "", + }, + { + name: "single path segment", + input: "ws://relay.example.com/path", + expected: "/path", + }, + { + name: "multiple path segments", + input: "wss://relay.example.com/nested/path/to/relay", + expected: "/nested/path/to/relay", + }, + { + name: "path with trailing slash", + input: "ws://relay.example.com/path/", + expected: "/path", + }, + { + name: "path with port", + input: "ws://relay.example.com:8080/path", + expected: "/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := relay.Path(); got != tt.expected { + t.Errorf("Path() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRelayURL_DTag(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no path", + input: "ws://relay.example.com", + expected: "ws://relay.example.com/", + }, + { + name: "with path", + input: "ws://relay.example.com/path", + expected: "ws://relay.example.com/path/", + }, + { + name: "with trailing slash in input", + input: "ws://relay.example.com/path/", + expected: "ws://relay.example.com/path/", + }, + { + name: "with port", + input: "wss://relay.example.com:443", + expected: "wss://relay.example.com:443/", + }, + { + name: "with port and path", + input: "wss://relay.example.com:443/path", + expected: "wss://relay.example.com:443/path/", + }, + { + name: "nested path", + input: "ws://relay.example.com/nested/path", + expected: "ws://relay.example.com/nested/path/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relay, err := New(tt.input) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := relay.DTag(); got != tt.expected { + t.Errorf("DTag() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRelayURL_Integration(t *testing.T) { + // Test that all methods work together correctly + input := "WSS://RELAY.EXAMPLE.COM:443/PATH/TO/RELAY/" + relay, err := New(input) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + // Verify normalization + if relay.String() != "wss://relay.example.com:443/path/to/relay" { + t.Errorf("String() = %v, want normalized URL", relay.String()) + } + + // Verify host extraction + if relay.Host() != "relay.example.com" { + t.Errorf("Host() = %v, want relay.example.com", relay.Host()) + } + + // Verify path extraction + if relay.Path() != "/path/to/relay" { + t.Errorf("Path() = %v, want /path/to/relay", relay.Path()) + } + + // Verify DTag format + if relay.DTag() != "wss://relay.example.com:443/path/to/relay/" { + t.Errorf("DTag() = %v, want URL with trailing slash", relay.DTag()) + } +} diff --git a/internal/relayurl/interface.go b/internal/relayurl/interface.go new file mode 100644 index 0000000..3217e50 --- /dev/null +++ b/internal/relayurl/interface.go @@ -0,0 +1,18 @@ +package relayurl + +// Represents a validated relay URL. +type RelayURL interface { + // Returns the canonical string form of the relay URL. + String() string + + // Returns the lowercase host without port, for host-based grouping. + Host() string + + // Returns relative path + Path() string + + // DTag returns the URL in the form expected for NIP-66 d-tags + // (normalized plus a trailing slash). + DTag() string +} + From 6540811ccabd7ab9cb5c2396bd1c68d0df5e7fd2 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Tue, 13 Jan 2026 16:41:29 +0300 Subject: [PATCH 2/2] refactor: replacing raw string with RelayURL --- analyze.go | 67 ++++++++++++++++++++++++++++++++------------------- collect.go | 64 ++++++++++++++++++++++++++++++++---------------- gen_router.go | 38 +++++++++++++++++++---------- util.go | 26 -------------------- 4 files changed, 110 insertions(+), 85 deletions(-) diff --git a/analyze.go b/analyze.go index 6a06327..9a51590 100644 --- a/analyze.go +++ b/analyze.go @@ -13,6 +13,7 @@ import ( "time" "github.com/nbd-wtf/go-nostr" + "github.com/relaytools/feedbuilder/internal/relayurl" ) type Event struct { @@ -143,12 +144,12 @@ func analyzeCmd(args []string) { pk := strings.ToLower(ev.PubKey) for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "r" { - url := normalizeURL(tag[1]) - if url == "" { + u, err := relayurl.New(tag[1]) + if err != nil { continue } - host := urlToHost(url) - if exHosts.has(host) { + url := u.String() + if exHosts.has(u.Host()) { continue } // If the URL points to an inbox endpoint, skip it and prefer a different URL for outbox @@ -215,8 +216,12 @@ func analyzeCmd(args []string) { // Collect all unique relay URLs we want to check allRelays := set{} - for url := range writeMap { - allRelays.add(normalizeURL(url)) + for rawURL := range writeMap { + u, err := relayurl.New(rawURL) + if err != nil { + continue + } + allRelays.add(u.String()) } monitorData := fetchNIP66MonitorData(monitorRelayList, allRelays, time.Duration(*monitorTimeout)*time.Second) @@ -236,16 +241,25 @@ func analyzeCmd(args []string) { // Filter writePairs to only include online relays var filteredPairs []string onlineRelays := set{} - for url, info := range monitorData { + for rawURL, info := range monitorData { if info.Status == "online" { - onlineRelays.add(normalizeURL(url)) + u, err := relayurl.New(rawURL) + if err != nil { + continue + } + onlineRelays.add(u.String()) } } for _, pair := range writePairs { fields := strings.Fields(pair) if len(fields) >= 2 { - relayURL := normalizeURL(strings.Join(fields[1:], " ")) + raw := strings.Join(fields[1:], " ") + u, err := relayurl.New(raw) + if err != nil { + continue + } + relayURL := u.String() if onlineRelays.has(relayURL) { filteredPairs = append(filteredPairs, pair) } @@ -367,9 +381,9 @@ func fetchNIP66MonitorData(monitorRelays []string, targetRelays set, timeout tim result := make(map[string]*RelayMonitorInfo) // Initialize all target relays as unknown - for url := range targetRelays { - result[url] = &RelayMonitorInfo{ - URL: url, + for relay := range targetRelays { + result[relay] = &RelayMonitorInfo{ + URL: relay, Status: "unknown", } } @@ -381,8 +395,8 @@ func fetchNIP66MonitorData(monitorRelays []string, targetRelays set, timeout tim // Convert target relays to slice for filter // NIP-66 requires normalized URLs with trailing slashes in d-tags var dTags []string - for url := range targetRelays { - normalized := url + for relay := range targetRelays { + normalized := relay if !strings.HasSuffix(normalized, "/") { normalized += "/" } @@ -474,8 +488,11 @@ func parseNIP66Event(event *nostr.Event, result map[string]*RelayMonitorInfo, mo // NIP-66 d-tags have trailing slashes, but our stored URLs don't for _, tag := range event.Tags { if len(tag) >= 2 && tag[0] == "d" { - // normalizeURL removes the trailing slash to match our stored URLs - relayURL = normalizeURL(tag[1]) + u, err := relayurl.New(tag[1]) + if err != nil { + continue + } + relayURL = u.String() break } } @@ -596,8 +613,8 @@ func writeMonitorReport(path string, data map[string]*RelayMonitorInfo) error { // Sort by URL var urls []string - for url := range data { - urls = append(urls, url) + for relayURL := range data { + urls = append(urls, relayURL) } sort.Strings(urls) @@ -606,8 +623,8 @@ func writeMonitorReport(path string, data map[string]*RelayMonitorInfo) error { fmt.Fprintln(w, "# Format: URL | Status | RTT-Open | RTT-Read | RTT-Write | Monitors | Last-Checked") fmt.Fprintln(w, "") - for _, url := range urls { - info := data[url] + for _, relayURL := range urls { + info := data[relayURL] lastChecked := "never" if info.LastChecked > 0 { lastChecked = time.Unix(info.LastChecked, 0).Format(time.RFC3339) @@ -640,12 +657,12 @@ func uniqueByHost(relayMap map[string]set) []string { have := set{} var out []string var urls []string - for url := range relayMap { - urls = append(urls, url) + for relay := range relayMap { + urls = append(urls, relay) } sort.Strings(urls) - for _, url := range urls { - h := urlToHost(url) + for _, relay := range urls { + h := urlToHost(relay) if h == "" { continue } @@ -653,7 +670,7 @@ func uniqueByHost(relayMap map[string]set) []string { continue } have.add(h) - out = append(out, url) + out = append(out, relay) } return out } diff --git a/collect.go b/collect.go index 59396e1..b692d9a 100644 --- a/collect.go +++ b/collect.go @@ -14,6 +14,7 @@ import ( "time" nostr "github.com/nbd-wtf/go-nostr" + "github.com/relaytools/feedbuilder/internal/relayurl" ) // eventLine represents a relay list event for serialized JSONL writes @@ -61,13 +62,33 @@ func collectCmd(args []string) { userPubkeyPath := filepath.Join(dataDirectory, "user_pubkey.txt") followSetsDir := filepath.Join(dataDirectory, "follow_sets") - relays := splitCSV(*relaysCSV) - if len(relays) == 0 { + relaysRaw := splitCSV(*relaysCSV) + if len(relaysRaw) == 0 { fmt.Fprintln(os.Stderr, "no relays provided") os.Exit(1) } - followRelayURL := *followRelay - if followRelayURL == "" { + relays := make([]relayurl.RelayURL, 0, len(relaysRaw)) + for _, raw := range relaysRaw { + r, err := relayurl.New(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: skipping invalid relay URL %q: %v\n", raw, err) + continue + } + relays = append(relays, r) + } + if len(relays) == 0 { + fmt.Fprintln(os.Stderr, "no valid relays provided") + os.Exit(1) + } + var followRelayURL relayurl.RelayURL + if *followRelay != "" { + r, err := relayurl.New(*followRelay) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid follow-relay URL: %v\n", err) + os.Exit(1) + } + followRelayURL = r + } else { followRelayURL = relays[0] } @@ -78,7 +99,7 @@ func collectCmd(args []string) { fmt.Println("\n==> Step 1: Fetching your relay list (kind 10002)") fmt.Printf(" Connecting to %s...\n", followRelayURL) - userRelays, err := fetchUserRelayList(ctx, followRelayURL, *pubkey, timeout) + userRelays, err := fetchUserRelayList(ctx, followRelayURL.String(), *pubkey, timeout) if err != nil { fmt.Fprintf(os.Stderr, "warning: failed to get your relay list from %s: %v\n", followRelayURL, err) // Continue anyway - not critical @@ -96,7 +117,7 @@ func collectCmd(args []string) { fmt.Println("\n==> Step 2: Fetching your follow list (kind 3)") fmt.Printf(" Connecting to %s...\n", followRelayURL) - follows, err := fetchFollows(ctx, followRelayURL, *pubkey, timeout) + follows, err := fetchFollows(ctx, followRelayURL.String(), *pubkey, timeout) if err != nil { fmt.Fprintf(os.Stderr, "failed to get follows from %s: %v\n", followRelayURL, err) os.Exit(1) @@ -111,7 +132,7 @@ func collectCmd(args []string) { if err := os.MkdirAll(followSetsDir, 0o755); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to create follow_sets directory: %v\n", err) } else { - followSets, err := fetchAndSaveFollowSets(ctx, followRelayURL, *pubkey, timeout, followSetsDir) + followSets, err := fetchAndSaveFollowSets(ctx, followRelayURL.String(), *pubkey, timeout, followSetsDir) if err != nil { fmt.Fprintf(os.Stderr, "warning: failed to get follow sets from %s: %v\n", followRelayURL, err) } else { @@ -216,18 +237,18 @@ func collectCmd(args []string) { semaphore := make(chan struct{}, *parallel) var wg sync.WaitGroup - for _, relayURL := range relays { + for _, relay := range relays { semaphore <- struct{}{} wg.Add(1) - go func(url string) { + go func(r relayurl.RelayURL) { defer wg.Done() defer func() { <-semaphore }() - if err := fetchAllBatches(ctx, url, batches, timeout, eventChan, progress); err != nil { + if err := fetchAllBatches(ctx, r, batches, timeout, eventChan, progress); err != nil { // Log errors but continue with other relays - fmt.Fprintf(os.Stderr, " ⚠ Error from %s: %v\n", url, err) + fmt.Fprintf(os.Stderr, " ⚠ Error from %s: %v\n", r, err) } - }(relayURL) + }(relay) } wg.Wait() @@ -301,11 +322,12 @@ func fetchUserRelayList(ctx context.Context, relayURL, pubkey string, timeout ti // Extract relay URLs from r-tags for _, tag := range event.Tags { if len(tag) >= 2 && tag[0] == "r" { - relayURL := strings.TrimSpace(tag[1]) - // Only include valid relay URLs (no query params, etc) - if isValidRelayURL(relayURL) { - relays = append(relays, relayURL) + raw := strings.TrimSpace(tag[1]) + u, err := relayurl.New(raw) + if err != nil { + continue } + relays = append(relays, u.String()) } } } @@ -587,24 +609,24 @@ func sanitizeFilename(s string) string { } // fetchAllBatches opens one connection to a relay and processes all batches sequentially -func fetchAllBatches(ctx context.Context, relayURL string, batches [][]string, timeout time.Duration, +func fetchAllBatches(ctx context.Context, relay relayurl.RelayURL, batches [][]string, timeout time.Duration, out chan<- eventLine, progress *progressTracker) error { // Connect once to the relay connectCtx, connectCancel := context.WithTimeout(ctx, timeout) defer connectCancel() - relay, err := nostr.RelayConnect(connectCtx, relayURL) + relayConn, err := nostr.RelayConnect(connectCtx, relay.String()) if err != nil { return fmt.Errorf("relay connect: %w", err) } - defer relay.Close() + defer relayConn.Close() // Process each batch with a new subscription on the same connection for batchIdx, authors := range batches { - if err := fetchBatch(ctx, relay, relayURL, authors, batchIdx, timeout, out); err != nil { + if err := fetchBatch(ctx, relayConn, relay.String(), authors, batchIdx, timeout, out); err != nil { // Log error but continue with next batch - fmt.Fprintf(os.Stderr, " ⚠ Error from %s batch %d: %v\n", relayURL, batchIdx+1, err) + fmt.Fprintf(os.Stderr, " ⚠ Error from %s batch %d: %v\n", relay, batchIdx+1, err) } progress.batchesDone.Add(1) } diff --git a/gen_router.go b/gen_router.go index 53da4dc..d9631e1 100644 --- a/gen_router.go +++ b/gen_router.go @@ -9,6 +9,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/relaytools/feedbuilder/internal/relayurl" ) type streamConfig struct { @@ -106,7 +108,9 @@ func greedySelectAndAssignN(relayAuthors map[string][]string, replicas int) ([]s assigned[r] = uniqueSorted(assigned[r]) } for i := range selected { - selected[i] = normalizeURL(selected[i]) + if u, err := relayurl.New(selected[i]); err == nil { + selected[i] = u.String() + } } return selected, assigned } @@ -152,12 +156,13 @@ func genRouterCmd(args []string) { continue } pk := strings.ToLower(fields[0]) - rurl := normalizeURL(strings.Join(fields[1:], " ")) - if _, ok := followsSet[pk]; !ok { + rurlRaw := strings.Join(fields[1:], " ") + u, err := relayurl.New(rurlRaw) + if err != nil { continue } - // Skip invalid relay URLs - if !isValidRelayURL(rurl) { + rurl := u.String() + if _, ok := followsSet[pk]; !ok { continue } relayAuthors[rurl] = append(relayAuthors[rurl], pk) @@ -177,7 +182,9 @@ func genRouterCmd(args []string) { var streams []streamConfig // Create per-relay down streams for selected relays with their assigned authors for _, relay := range selected { - relay = normalizeURL(relay) + if u, err := relayurl.New(relay); err == nil { + relay = u.String() + } auths := assigned[relay] if len(auths) == 0 { continue @@ -257,11 +264,13 @@ func genRouterCmd(args []string) { // Load user's relay list from file and filter out invalid URLs userRelaysRaw := readLinesIfExists(userRelayListFile) - var userRelays []string - for _, relay := range userRelaysRaw { - if isValidRelayURL(relay) { - userRelays = append(userRelays, relay) + userRelays := make([]string, 0, len(userRelaysRaw)) + for _, relayLine := range userRelaysRaw { + u, err := relayurl.New(relayLine) + if err != nil { + continue } + userRelays = append(userRelays, u.String()) } if len(userRelays) == 0 { fmt.Fprintf(os.Stderr, "warning: no user relay list found at %s, skipping notification streams\n", userRelayListFile) @@ -271,7 +280,6 @@ func genRouterCmd(args []string) { // Add stream for notifications mentioning user (inbox) for _, relay := range userRelays { - relay = normalizeURL(relay) name := fmt.Sprintf("notifs_inbox_%s", safeName(relay)) streams = append(streams, streamConfig{ Name: name, @@ -300,7 +308,9 @@ func readLinesMust(path string) []string { os.Exit(1) } for i := range lines { - lines[i] = normalizeURL(lines[i]) + if u, err := relayurl.New(lines[i]); err == nil { + lines[i] = u.String() + } } return lines } @@ -311,7 +321,9 @@ func readLinesIfExists(path string) []string { return nil } for i := range lines { - lines[i] = normalizeURL(lines[i]) + if u, err := relayurl.New(lines[i]); err == nil { + lines[i] = u.String() + } } return lines } diff --git a/util.go b/util.go index 2dda7f3..4067728 100644 --- a/util.go +++ b/util.go @@ -1,31 +1,5 @@ package main -import "strings" - -// normalizeURL normalizes a relay URL by trimming whitespace, converting to lowercase, and removing trailing slashes -func normalizeURL(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.TrimSuffix(s, "/") - return s -} - -// isValidRelayURL checks if a URL is a valid relay URL -func isValidRelayURL(s string) bool { - s = strings.TrimSpace(s) - if s == "" { - return false - } - // Must start with ws:// or wss:// - if !strings.HasPrefix(s, "ws://") && !strings.HasPrefix(s, "wss://") { - return false - } - // Cannot contain query parameters or fragments - if strings.Contains(s, "?") || strings.Contains(s, "#") { - return false - } - return true -} - // isHex64 validates that a string is exactly 64 hexadecimal characters func isHex64(s string) bool { if len(s) != 64 {