diff --git a/packages/ndk/lib/shared/helpers/relay_helper.dart b/packages/ndk/lib/shared/helpers/relay_helper.dart index 85aa66454..8bc846fcb 100644 --- a/packages/ndk/lib/shared/helpers/relay_helper.dart +++ b/packages/ndk/lib/shared/helpers/relay_helper.dart @@ -1,15 +1,18 @@ // ignore: public_member_api_docs, non_constant_identifier_names final RegExp RELAY_URL_REGEX = RegExp( - r'^(wss?:\/\/)([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[^:]+):?([0-9]{1,5})?$'); + r'^(wss?:\/\/)([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*|[0-9]{1,3}(?:\.[0-9]{1,3}){3}):?([0-9]{1,5})?(\/[^\s]*)?$'); String? cleanRelayUrl(String adr) { + adr = adr.trim(); if (adr.endsWith("/")) { adr = adr.substring(0, adr.length - 1); } if (adr.contains("%")) { adr = Uri.decodeComponent(adr); } - adr = adr.trim(); + // Remove extra slashes after protocol (e.g., wss:/// -> wss://) + adr = adr.replaceFirstMapped( + RegExp(r'^(wss?:)\/{3,}'), (match) => '${match.group(1)}//'); if (!adr.contains(RELAY_URL_REGEX)) { return null; } diff --git a/packages/ndk/test/relays/trailing_slash_test.dart b/packages/ndk/test/relays/trailing_slash_test.dart index a896584b4..c24600ba7 100644 --- a/packages/ndk/test/relays/trailing_slash_test.dart +++ b/packages/ndk/test/relays/trailing_slash_test.dart @@ -313,4 +313,21 @@ void main() { ndk.destroy(); }); }); + + group('Triple slash', () { + test('query triple slashes relays: wss:///', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final query = ndk.requests.query( + filters: [ + Filter(kinds: [0]), + ], + explicitRelays: ["wss:///example.com"], + ); + + await query.future; + + ndk.destroy(); + }); + }); } diff --git a/packages/ndk/test/shared/helpers/relay_helper_test.dart b/packages/ndk/test/shared/helpers/relay_helper_test.dart new file mode 100644 index 000000000..97a3d64a7 --- /dev/null +++ b/packages/ndk/test/shared/helpers/relay_helper_test.dart @@ -0,0 +1,164 @@ +import 'package:ndk/shared/helpers/relay_helper.dart'; +import 'package:test/test.dart'; + +void main() { + group('cleanRelayUrl', () { + group('valid URLs', () { + test('accepts valid wss URL with port + path', () { + expect(cleanRelayUrl('wss://relay.damus.io:5000/abc/aa.co.mm'), 'wss://relay.damus.io:5000/abc/aa.co.mm'); + }); + + test('accepts valid wss URL', () { + expect(cleanRelayUrl('wss://relay.damus.io'), 'wss://relay.damus.io'); + }); + + test('accepts valid ws URL', () { + expect(cleanRelayUrl('ws://localhost'), 'ws://localhost'); + }); + + test('accepts URL with port', () { + expect(cleanRelayUrl('wss://relay.example.com:8080'), + 'wss://relay.example.com:8080'); + }); + + test('accepts URL with subdomain', () { + expect(cleanRelayUrl('wss://nostr.relay.example.com'), + 'wss://nostr.relay.example.com'); + }); + + test('accepts IP address', () { + expect(cleanRelayUrl('wss://192.168.1.1'), 'wss://192.168.1.1'); + }); + + test('accepts IP address with port', () { + expect( + cleanRelayUrl('wss://192.168.1.1:8080'), 'wss://192.168.1.1:8080'); + }); + + test('accepts localhost', () { + expect(cleanRelayUrl('ws://localhost'), 'ws://localhost'); + }); + + test('accepts localhost with port', () { + expect(cleanRelayUrl('ws://localhost:7777'), 'ws://localhost:7777'); + }); + }); + + group('URL cleaning', () { + test('removes trailing slash', () { + expect(cleanRelayUrl('wss://relay.damus.io/'), 'wss://relay.damus.io'); + }); + + test('trims whitespace', () { + expect( + cleanRelayUrl(' wss://relay.damus.io '), 'wss://relay.damus.io'); + }); + + test('decodes percent-encoded URL', () { + expect(cleanRelayUrl('wss://relay.example%2Ecom'), + 'wss://relay.example.com'); + }); + + test('fixes triple slash URL', () { + expect(cleanRelayUrl('wss:///relay.damus.io'), 'wss://relay.damus.io'); + }); + + test('fixes multiple extra slashes', () { + expect(cleanRelayUrl('wss:////relay.damus.io'), 'wss://relay.damus.io'); + }); + }); + + group('invalid URLs', () { + test('returns null for empty string', () { + expect(cleanRelayUrl(''), null); + }); + + test('returns null for URL without protocol', () { + expect(cleanRelayUrl('relay.damus.io'), null); + }); + + test('returns null for http URL', () { + expect(cleanRelayUrl('http://relay.damus.io'), null); + }); + + test('returns null for https URL', () { + expect(cleanRelayUrl('https://relay.damus.io'), null); + }); + + test('returns null for URL with only protocol', () { + expect(cleanRelayUrl('wss://'), null); + }); + + test('returns null for triple slash with no host', () { + expect(cleanRelayUrl('wss:///'), null); + }); + + test('returns null for invalid characters in host', () { + expect(cleanRelayUrl('wss://relay space.com'), null); + }); + + test('returns null for host starting with hyphen', () { + expect(cleanRelayUrl('wss://-relay.com'), null); + }); + + test('returns null for host ending with hyphen', () { + expect(cleanRelayUrl('wss://relay-.com'), null); + }); + }); + }); + + group('cleanRelayUrls', () { + test('returns empty list for empty input', () { + expect(cleanRelayUrls([]), []); + }); + + test('filters out invalid URLs', () { + final urls = [ + 'wss://relay.damus.io', + 'invalid-url', + 'wss://nos.lol', + ]; + expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); + }); + + test('cleans valid URLs', () { + final urls = [ + 'wss://relay.damus.io/', + ' wss://nos.lol ', + ]; + expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); + }); + + test('returns empty list when all URLs are invalid', () { + final urls = [ + 'invalid', + 'http://example.com', + '', + ]; + expect(cleanRelayUrls(urls), []); + }); + + test('fixes triple slash URLs in list', () { + final urls = [ + 'wss:///relay.damus.io', + 'wss://nos.lol', + ]; + expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); + }); + }); + + group('RELAY_URL_REGEX', () { + test('matches valid relay URL pattern', () { + expect(RELAY_URL_REGEX.hasMatch('wss://relay.damus.io'), true); + expect(RELAY_URL_REGEX.hasMatch('ws://localhost'), true); + expect(RELAY_URL_REGEX.hasMatch('wss://192.168.1.1'), true); + expect(RELAY_URL_REGEX.hasMatch('wss://relay.example.com:8080'), true); + }); + + test('does not match invalid patterns', () { + expect(RELAY_URL_REGEX.hasMatch('http://example.com'), false); + expect(RELAY_URL_REGEX.hasMatch('wss://'), false); + expect(RELAY_URL_REGEX.hasMatch('wss:///example.com'), false); + }); + }); +}