diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 0cd58d47a7..1c52d82a3a 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -151,7 +151,7 @@ class InviteJoinNotifier extends Notifier { final community = Community.create( name: _communityNameFromClaim(claim, invite.relayUrl), - relayUrl: invite.relayUrl, + relayUrl: _normalizeRelayUrlForStorage(invite.relayUrl), pubkey: keys.public, nsec: keys.nsec, ); @@ -274,3 +274,20 @@ String _friendlyInviteError(Object error) { } return 'Could not join this community: $message'; } + +/// Normalize a relay URL from websocket scheme to HTTP scheme for storage. +/// +/// The invite deep-link parser produces `wss://` or `ws://` URLs, but the +/// [Community.relayUrl] convention (matching the pairing flow) is HTTP-scheme +/// (`https://` / `http://`). [RelayConfig] then derives both `wsUrl` and +/// `httpUrl` from the stored base URL, so storing the canonical HTTP form +/// keeps all downstream derivations correct. +String _normalizeRelayUrlForStorage(String relayUrl) { + final uri = Uri.parse(relayUrl); + final scheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => uri.scheme, // already http/https — pass through + }; + return uri.replace(scheme: scheme).toString(); +} diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index 20f65b01cb..a93f3bedb9 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -22,10 +22,15 @@ class CommunityStorage { : _secure = secure ?? const FlutterSecureStorage(); /// Load all communities. On first call, migrates legacy single-community - /// credentials if present. + /// credentials if present. Also canonicalizes any stored `wss://`/`ws://` + /// relay URLs to `https://`/`http://` (invite-created communities before + /// the normalization fix stored websocket-scheme URLs directly). Future> loadAll() async { final raw = await _secure.read(key: _keyCommunities); - if (raw != null) return _decodeList(raw); + if (raw != null) { + final communities = _decodeList(raw); + return _migrateRelaySchemes(communities); + } final legacyCommunities = await _secure.read(key: _legacyCommunities); if (legacyCommunities != null) { @@ -108,4 +113,34 @@ class CommunityStorage { final json = jsonEncode(communities.map((item) => item.toJson()).toList()); await _secure.write(key: _keyCommunities, value: json); } + + /// Rewrite stored `wss://`/`ws://` relay URLs to `https://`/`http://`. + /// + /// Communities created via the invite deep-link flow before the + /// normalization fix stored the raw websocket-scheme URL. This migration + /// canonicalizes them on first load so [RelayConfig.httpUrl] and + /// downstream HTTP consumers (media upload, `/query`) work correctly. + Future> _migrateRelaySchemes( + List communities, + ) async { + var dirty = false; + final migrated = communities.map((community) { + final uri = Uri.tryParse(community.relayUrl); + if (uri == null) return community; + final scheme = uri.scheme; + if (scheme == 'wss' || scheme == 'ws') { + dirty = true; + final httpScheme = scheme == 'wss' ? 'https' : 'http'; + return community.copyWith( + relayUrl: uri.replace(scheme: httpScheme).toString(), + ); + } + return community; + }).toList(); + + if (dirty) { + await _saveList(migrated); + } + return migrated; + } } diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 3982fb48d1..adff75bc15 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -121,7 +121,7 @@ class MediaGetAuthService { final mediaGetAuthServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec); + return MediaGetAuthService(baseUrl: config.httpUrl, nsec: config.nsec); }); Map mediaGetHeadersFor(WidgetRef ref, String url) { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index b868fc2845..e319c2d674 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -680,7 +680,7 @@ final mediaUploadServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); final picker = ImagePicker(); final service = MediaUploadService( - baseUrl: config.baseUrl, + baseUrl: config.httpUrl, nsec: config.nsec, pickGalleryImage: () => picker.pickImage( source: ImageSource.gallery, diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 97b88dd3df..a3b71f085d 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -17,10 +17,31 @@ class RelayConfig { const RelayConfig({required this.baseUrl, this.nsec}); - /// Derive the websocket URL from the HTTP base URL. + /// Derive the websocket URL from the base URL. + /// + /// Handles both HTTP-scheme (`https` → `wss`, `http` → `ws`) and + /// already-websocket URLs (`wss`/`ws` passed through unchanged). The invite + /// join flow stores `wss://` relay URLs directly from the parsed deep link; + /// without this, those URLs were incorrectly downgraded to `ws://`. String get wsUrl { final uri = Uri.parse(baseUrl); - final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; + final scheme = switch (uri.scheme) { + 'https' || 'wss' => 'wss', + _ => 'ws', + }; + return uri.replace(scheme: scheme).toString(); + } + + /// Derive the HTTP base URL for REST endpoints (`/query`, `/upload`, etc.). + /// + /// Handles both HTTP-scheme (passed through) and websocket-scheme URLs + /// (`wss` → `https`, `ws` → `http`). + String get httpUrl { + final uri = Uri.parse(baseUrl); + final scheme = switch (uri.scheme) { + 'wss' || 'https' => 'https', + _ => 'http', + }; return uri.replace(scheme: scheme).toString(); } } @@ -86,7 +107,7 @@ final myPubkeyProvider = Provider((ref) { /// through the relay WebSocket session. final relayClientProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - final client = RelayClient(baseUrl: config.baseUrl); + final client = RelayClient(baseUrl: config.httpUrl); ref.onDispose(client.dispose); return client; }); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 9c167e10b1..1d95829bbc 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -133,7 +133,7 @@ class RelaySessionNotifier extends Notifier { Duration timeout = const Duration(seconds: 8), }) async { final config = ref.read(relayConfigProvider); - final url = Uri.parse(config.baseUrl).resolve('/query').toString(); + final url = Uri.parse(config.httpUrl).resolve('/query').toString(); final bodyBytes = utf8.encode( jsonEncode(filters.map((filter) => filter.toJson()).toList()), ); diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 29158f7910..07fe33281b 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -65,7 +65,11 @@ void main() { final stored = (await storage.loadAll()).single; expect(state.status, InviteJoinStatus.switchedExisting); expect(await storage.loadActiveId(), existing.id); - expect(stored.relayUrl, existingRelayUrl); + // After loading, wss:// URLs are migrated to https:// by storage. + final expectedStoredUrl = existingRelayUrl.startsWith('wss://') + ? existingRelayUrl.replaceFirst('wss://', 'https://') + : existingRelayUrl; + expect(stored.relayUrl, expectedStoredUrl); expect(stored.pubkey, 'old-pubkey'); expect(stored.nsec, 'old-nsec'); expect(generatedKeys, 0); @@ -132,13 +136,58 @@ void main() { expect(auth.authenticatedCommunities, hasLength(1)); expect( auth.authenticatedCommunities.single.relayUrl, - 'wss://relay.example.com', + 'https://relay.example.com', ); expect(auth.authenticatedCommunities.single.pubkey, keys.public); expect(auth.authenticatedCommunities.single.nsec, keys.nsec); }, ); + for (final entry in [ + ('wss://relay.example.com', 'https://relay.example.com'), + ('ws://local.dev:3000', 'http://local.dev:3000'), + ]) { + test( + 'confirmJoin normalizes ${entry.$1} to ${entry.$2} for storage', + () async { + final keys = nostr.Keys.generate(); + final storage = CommunityStorage(secure: FakeSecureStorage()); + final auth = _RecordingAuthNotifier(); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + authProvider.overrideWith(() => auth), + inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'status': 'joined', + 'host': 'relay.example.com', + 'role': 'member', + }), + 200, + ); + }), + ), + ], + ); + addTearDown(container.dispose); + + await container + .read(inviteJoinProvider.notifier) + .prepare(InviteDeepLink(relayUrl: entry.$1, code: 'code')); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); + expect(auth.authenticatedCommunities.single.relayUrl, entry.$2); + }, + ); + } + test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index dc0835e622..a8a658d90e 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -232,6 +232,48 @@ void main() { final loaded = await storage.loadAll(); expect(loaded.first.name, 'Local Dev'); }); + + test('migrates wss:// relay URLs to https:// on load', () async { + final community = Community.create( + name: 'Invite Community', + relayUrl: 'wss://hosted.relay.example', + pubkey: 'pub1', + nsec: 'nsec1', + ); + fakeSecure['buzz_communities'] = jsonEncode([community.toJson()]); + + final loaded = await storage.loadAll(); + + expect(loaded.single.relayUrl, 'https://hosted.relay.example'); + // Verify persisted so re-load doesn't re-migrate. + final reloaded = await storage.loadAll(); + expect(reloaded.single.relayUrl, 'https://hosted.relay.example'); + }); + + test('migrates ws:// relay URLs to http:// on load', () async { + final community = Community.create( + name: 'Dev Community', + relayUrl: 'ws://localhost:3000', + pubkey: 'pub2', + ); + fakeSecure['buzz_communities'] = jsonEncode([community.toJson()]); + + final loaded = await storage.loadAll(); + + expect(loaded.single.relayUrl, 'http://localhost:3000'); + }); + + test('does not rewrite already-https communities', () async { + final community = Community.create( + name: 'Normal', + relayUrl: 'https://relay.example.com', + ); + fakeSecure['buzz_communities'] = jsonEncode([community.toJson()]); + + final loaded = await storage.loadAll(); + + expect(loaded.single.relayUrl, 'https://relay.example.com'); + }); }); }); } diff --git a/mobile/test/shared/relay/relay_config_test.dart b/mobile/test/shared/relay/relay_config_test.dart new file mode 100644 index 0000000000..7b55c355b4 --- /dev/null +++ b/mobile/test/shared/relay/relay_config_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; + +void main() { + group('RelayConfig.wsUrl', () { + test('converts https to wss', () { + const config = RelayConfig(baseUrl: 'https://relay.example'); + expect(config.wsUrl, 'wss://relay.example'); + }); + + test('converts http to ws', () { + const config = RelayConfig(baseUrl: 'http://relay.example'); + expect(config.wsUrl, 'ws://relay.example'); + }); + + test('preserves wss unchanged', () { + const config = RelayConfig(baseUrl: 'wss://relay.example'); + expect(config.wsUrl, 'wss://relay.example'); + }); + + test('preserves ws unchanged', () { + const config = RelayConfig(baseUrl: 'ws://relay.example'); + expect(config.wsUrl, 'ws://relay.example'); + }); + + test('preserves port with https', () { + const config = RelayConfig(baseUrl: 'https://relay.example:8443'); + expect(config.wsUrl, 'wss://relay.example:8443'); + }); + + test('preserves port with wss', () { + const config = RelayConfig(baseUrl: 'wss://relay.example:8443'); + expect(config.wsUrl, 'wss://relay.example:8443'); + }); + + test('preserves path with http', () { + const config = RelayConfig(baseUrl: 'http://relay.example:3000/base'); + expect(config.wsUrl, 'ws://relay.example:3000/base'); + }); + }); + + group('RelayConfig.httpUrl', () { + test('preserves https unchanged', () { + const config = RelayConfig(baseUrl: 'https://relay.example'); + expect(config.httpUrl, 'https://relay.example'); + }); + + test('preserves http unchanged', () { + const config = RelayConfig(baseUrl: 'http://relay.example'); + expect(config.httpUrl, 'http://relay.example'); + }); + + test('converts wss to https', () { + const config = RelayConfig(baseUrl: 'wss://relay.example'); + expect(config.httpUrl, 'https://relay.example'); + }); + + test('converts ws to http', () { + const config = RelayConfig(baseUrl: 'ws://relay.example'); + expect(config.httpUrl, 'http://relay.example'); + }); + + test('preserves port with wss', () { + const config = RelayConfig(baseUrl: 'wss://relay.example:8443'); + expect(config.httpUrl, 'https://relay.example:8443'); + }); + + test('preserves path with ws', () { + const config = RelayConfig(baseUrl: 'ws://relay.example:3000/base'); + expect(config.httpUrl, 'http://relay.example:3000/base'); + }); + }); + + group('existing wss:// community HTTP derivation', () { + // Exercises the scenario where a community was stored with a wss:// URL + // (invite-created before migration). Even without the migration running, + // httpUrl should produce a valid HTTPS URL for REST endpoints. + test('httpUrl converts stored wss:// community URL to https', () { + // Simulates a RelayConfig built from an un-migrated community. + const config = RelayConfig( + baseUrl: 'wss://hosted.communities.buzz.xyz', + nsec: 'nsec1test', + ); + expect(config.httpUrl, 'https://hosted.communities.buzz.xyz'); + expect(config.wsUrl, 'wss://hosted.communities.buzz.xyz'); + }); + + test('httpUrl converts stored ws:// community URL to http', () { + const config = RelayConfig(baseUrl: 'ws://localhost:3000'); + expect(config.httpUrl, 'http://localhost:3000'); + expect(config.wsUrl, 'ws://localhost:3000'); + }); + }); +}