From 338962eccfcbcea61908fd1e0696400d6f88e9b3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 26 Jan 2026 20:07:16 +0100 Subject: [PATCH] normalize and deduplicate relays --- src/main.ts | 1 + src/nostr/relayClient.ts | 10 ++++- src/relayManager.ts | 88 +++++++++++++++++++++++++++++++++++----- src/ui/settingsTab.ts | 14 ++++--- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/main.ts b/src/main.ts index 1e7f8ff..32ae64d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -256,6 +256,7 @@ export default class ScriptoriumPlugin extends Plugin { return; } + // Relays are already normalized and deduplicated by getWriteRelays new Notice(`Publishing ${events.length} event(s) to ${writeRelays.length} relay(s)...`); const results = await publishEventsWithRetry(writeRelays, events, this.settings.privateKey); diff --git a/src/nostr/relayClient.ts b/src/nostr/relayClient.ts index a617068..4897745 100644 --- a/src/nostr/relayClient.ts +++ b/src/nostr/relayClient.ts @@ -2,6 +2,7 @@ import { Relay } from "nostr-tools"; import { SignedEvent, PublishingResult } from "../types"; import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; import { safeConsoleError } from "../utils/security"; +import { deduplicateRelayUrls, normalizeRelayUrl } from "../relayManager"; /** * Publish a single event to a relay @@ -118,11 +119,18 @@ export async function publishEventsWithRetry( privkey: string, maxRetries: number = 3 ): Promise { + // Normalize and deduplicate relay URLs before publishing + const normalizedUrls = deduplicateRelayUrls(relayUrls.map(url => normalizeRelayUrl(url))); + + if (normalizedUrls.length === 0) { + return []; + } + let attempts = 0; let results: PublishingResult[][] = []; while (attempts < maxRetries) { - results = await publishEventsToRelays(relayUrls, events, privkey); + results = await publishEventsToRelays(normalizedUrls, events, privkey); // Check if all events succeeded on at least one relay const allSucceeded = results.some((relayResults) => diff --git a/src/relayManager.ts b/src/relayManager.ts index 8e427b9..b7003c8 100644 --- a/src/relayManager.ts +++ b/src/relayManager.ts @@ -29,7 +29,7 @@ export function parseRelayList(event: any): RelayInfo[] { for (const tag of event.tags) { if (tag[0] === "r" && tag[1]) { - const url = tag[1]; + const url = normalizeRelayUrl(tag[1]); const read = tag.length > 2 ? tag[2] === "read" || tag[2] === undefined : true; const write = tag.length > 2 ? tag[2] === "write" || tag[2] === undefined : true; @@ -77,7 +77,8 @@ export async function fetchRelayListFromRelay( clearTimeout(timer); relay?.close(); const relayList = parseRelayList(event); - resolve(relayList.length > 0 ? relayList : null); + const normalized = normalizeRelayList(relayList); + resolve(normalized.length > 0 ? normalized : null); }, oneose: () => { clearTimeout(timer); @@ -123,10 +124,10 @@ export async function fetchRelayList( } } - // If none found, return default fallback + // If none found, return default fallback (normalized) return [ { - url: DEFAULT_FALLBACK_RELAY, + url: normalizeRelayUrl(DEFAULT_FALLBACK_RELAY), read: true, write: true, }, @@ -134,17 +135,82 @@ export async function fetchRelayList( } /** - * Get write relays from relay list + * Normalize a relay URL + * - Removes trailing slashes + * - Ensures lowercase + * - Validates wss:// or ws:// protocol + */ +export function normalizeRelayUrl(url: string): string { + if (!url) return url; + + let normalized = url.trim().toLowerCase(); + + // Remove trailing slashes + normalized = normalized.replace(/\/+$/, ""); + + // Ensure protocol is present + if (!normalized.startsWith("wss://") && !normalized.startsWith("ws://")) { + // Default to wss:// if no protocol + normalized = "wss://" + normalized; + } + + return normalized; +} + +/** + * Deduplicate relay URLs by normalizing and comparing + */ +export function deduplicateRelayUrls(urls: string[]): string[] { + const seen = new Set(); + const unique: string[] = []; + + for (const url of urls) { + const normalized = normalizeRelayUrl(url); + if (!seen.has(normalized)) { + seen.add(normalized); + unique.push(normalized); + } + } + + return unique; +} + +/** + * Normalize and deduplicate relay list + */ +export function normalizeRelayList(relayList: RelayInfo[]): RelayInfo[] { + const seen = new Set(); + const normalized: RelayInfo[] = []; + + for (const relay of relayList) { + const normalizedUrl = normalizeRelayUrl(relay.url); + if (!seen.has(normalizedUrl)) { + seen.add(normalizedUrl); + normalized.push({ + url: normalizedUrl, + read: relay.read, + write: relay.write, + }); + } + } + + return normalized; +} + +/** + * Get write relays from relay list (normalized and deduplicated) */ export function getWriteRelays(relayList: RelayInfo[]): string[] { - return relayList.filter((r) => r.write).map((r) => r.url); + const writeRelays = relayList.filter((r) => r.write).map((r) => r.url); + return deduplicateRelayUrls(writeRelays); } /** - * Get read relays from relay list + * Get read relays from relay list (normalized and deduplicated) */ export function getReadRelays(relayList: RelayInfo[]): string[] { - return relayList.filter((r) => r.read).map((r) => r.url); + const readRelays = relayList.filter((r) => r.read).map((r) => r.url); + return deduplicateRelayUrls(readRelays); } /** @@ -162,12 +228,12 @@ export function addTheCitadelIfMissing(relayList: RelayInfo[]): RelayInfo[] { return relayList; } - return [ + return normalizeRelayList([ ...relayList, { - url: DEFAULT_FALLBACK_RELAY, + url: normalizeRelayUrl(DEFAULT_FALLBACK_RELAY), read: true, write: true, }, - ]; + ]); } diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index 8468787..ed7aa29 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -1,7 +1,7 @@ import { App, PluginSettingTab, Setting, Notice } from "obsidian"; import ScriptoriumPlugin from "../main"; import { EventKind } from "../types"; -import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel, getReadRelays } from "../relayManager"; +import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel, getReadRelays, normalizeRelayUrl, normalizeRelayList } from "../relayManager"; import { getPubkeyFromPrivkey, getNpubFromPrivkey } from "../nostr/eventBuilder"; import { fetchUserProfile } from "../nostr/profileFetcher"; @@ -240,8 +240,9 @@ export class ScriptoriumSettingTab extends PluginSettingTab { if (this.plugin.settings.suggestTheCitadel && !includesTheCitadel(relayList)) { finalList = addTheCitadelIfMissing(relayList); } - - this.plugin.settings.relayList = finalList; + + // Normalize and deduplicate before saving + this.plugin.settings.relayList = normalizeRelayList(finalList); await this.plugin.saveSettings(); await this.display(); } catch (error: any) { @@ -290,12 +291,15 @@ export class ScriptoriumSettingTab extends PluginSettingTab { const input = button.buttonEl.previousElementSibling as HTMLInputElement; const url = input.value.trim(); if (url) { - if (!this.plugin.settings.relayList.some((r) => r.url === url)) { + const normalizedUrl = normalizeRelayUrl(url); + if (!this.plugin.settings.relayList.some((r) => normalizeRelayUrl(r.url) === normalizedUrl)) { this.plugin.settings.relayList.push({ - url, + url: normalizedUrl, read: true, write: true, }); + // Normalize and deduplicate the entire list + this.plugin.settings.relayList = normalizeRelayList(this.plugin.settings.relayList); await this.plugin.saveSettings(); await this.display(); }