Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion mobile/lib/features/invites/invite_join_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {

final community = Community.create(
name: _communityNameFromClaim(claim, invite.relayUrl),
relayUrl: invite.relayUrl,
relayUrl: _normalizeRelayUrlForStorage(invite.relayUrl),
pubkey: keys.public,
nsec: keys.nsec,
);
Expand Down Expand Up @@ -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();
}
39 changes: 37 additions & 2 deletions mobile/lib/shared/community/community_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Community>> 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) {
Expand Down Expand Up @@ -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<List<Community>> _migrateRelaySchemes(
List<Community> 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;
}
}
2 changes: 1 addition & 1 deletion mobile/lib/shared/relay/media_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ class MediaGetAuthService {

final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);
return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec);
return MediaGetAuthService(baseUrl: config.httpUrl, nsec: config.nsec);
});

Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/shared/relay/media_upload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ final mediaUploadServiceProvider = Provider<MediaUploadService>((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,
Expand Down
27 changes: 24 additions & 3 deletions mobile/lib/shared/relay/relay_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -86,7 +107,7 @@ final myPubkeyProvider = Provider<String?>((ref) {
/// through the relay WebSocket session.
final relayClientProvider = Provider<RelayClient>((ref) {
final config = ref.watch(relayConfigProvider);
final client = RelayClient(baseUrl: config.baseUrl);
final client = RelayClient(baseUrl: config.httpUrl);
ref.onDispose(client.dispose);
return client;
});
2 changes: 1 addition & 1 deletion mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
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()),
);
Expand Down
53 changes: 51 additions & 2 deletions mobile/test/features/invites/invite_join_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions mobile/test/shared/community/community_storage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
}
94 changes: 94 additions & 0 deletions mobile/test/shared/relay/relay_config_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}