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
40 changes: 25 additions & 15 deletions lib/auth/models/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Server {
final String id;
final String name;
final String address;
final String connectionAddress;
final String version;
final ServerType serverType;
final String? loginDisclaimer;
Expand All @@ -16,18 +17,21 @@ class Server {
required this.id,
required this.name,
required this.address,
String? connectionAddress,
required this.version,
required this.serverType,
this.loginDisclaimer,
this.splashscreenEnabled = false,
this.setupCompleted = true,
required this.dateAdded,
DateTime? dateLastAccessed,
}) : dateLastAccessed = dateLastAccessed ?? dateAdded;
}) : connectionAddress = connectionAddress ?? address,
dateLastAccessed = dateLastAccessed ?? dateAdded;

Server copyWith({
String? name,
String? address,
String? connectionAddress,
String? version,
ServerType? serverType,
String? loginDisclaimer,
Expand All @@ -39,6 +43,7 @@ class Server {
id: id,
name: name ?? this.name,
address: address ?? this.address,
connectionAddress: connectionAddress ?? this.connectionAddress,
version: version ?? this.version,
serverType: serverType ?? this.serverType,
loginDisclaimer: loginDisclaimer ?? this.loginDisclaimer,
Expand All @@ -50,22 +55,25 @@ class Server {
}

Map<String, dynamic> toJson() => {
'name': name,
'address': address,
'version': version,
'serverType': serverType.name,
'loginDisclaimer': loginDisclaimer,
'splashscreenEnabled': splashscreenEnabled,
'setupCompleted': setupCompleted,
'dateAdded': dateAdded.toIso8601String(),
'dateLastAccessed': dateLastAccessed.toIso8601String(),
};
'name': name,
'address': address,
'connectionAddress': connectionAddress,
'version': version,
'serverType': serverType.name,
'loginDisclaimer': loginDisclaimer,
'splashscreenEnabled': splashscreenEnabled,
'setupCompleted': setupCompleted,
'dateAdded': dateAdded.toIso8601String(),
'dateLastAccessed': dateLastAccessed.toIso8601String(),
};

factory Server.fromJson(String id, Map<String, dynamic> json) {
final address = json['address'] as String? ?? '';
return Server(
id: id,
name: json['name'] as String? ?? '',
address: json['address'] as String? ?? '',
address: address,
connectionAddress: json['connectionAddress'] as String? ?? address,
version: json['version'] as String? ?? '',
serverType: ServerType.values.firstWhere(
(t) => t.name == json['serverType'],
Expand All @@ -74,10 +82,12 @@ class Server {
loginDisclaimer: json['loginDisclaimer'] as String?,
splashscreenEnabled: json['splashscreenEnabled'] as bool? ?? false,
setupCompleted: json['setupCompleted'] as bool? ?? true,
dateAdded: DateTime.tryParse(json['dateAdded'] as String? ?? '') ??
dateAdded:
DateTime.tryParse(json['dateAdded'] as String? ?? '') ??
DateTime.now(),
dateLastAccessed:
DateTime.tryParse(json['dateLastAccessed'] as String? ?? ''),
dateLastAccessed: DateTime.tryParse(
json['dateLastAccessed'] as String? ?? '',
),
);
}
}
37 changes: 26 additions & 11 deletions lib/auth/repositories/server_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ class ServerRepository {
final seenEndpoints = <String, String>{};

for (final server in stored) {
final normalizedAddress = normalizeServerBaseUrl(server.address);
final normalizedConnectionAddress = normalizeServerBaseUrl(
server.connectionAddress,
);
final normalizedServer =
normalizedAddress != server.address && normalizedAddress.isNotEmpty
? server.copyWith(address: normalizedAddress)
normalizedConnectionAddress != server.connectionAddress &&
normalizedConnectionAddress.isNotEmpty
? server.copyWith(connectionAddress: normalizedConnectionAddress)
: server;

if (normalizedAddress != server.address && normalizedAddress.isNotEmpty) {
if (normalizedConnectionAddress != server.connectionAddress &&
normalizedConnectionAddress.isNotEmpty) {
await _authStore.putServer(normalizedServer);
}

final endpointKey = _endpointIdentity(normalizedServer.address);
final endpointKey = _endpointIdentity(normalizedServer.connectionAddress);
final existingServerId = seenEndpoints[endpointKey];
if (existingServerId != null && existingServerId != normalizedServer.id) {
await _authStore.removeServer(normalizedServer.id);
Expand All @@ -59,7 +63,8 @@ class ServerRepository {
}

Future<Server?> addServer(String address) async {
address = normalizeServerBaseUrl(address.trim());
final enteredAddress = address.trim();
address = normalizeServerBaseUrl(enteredAddress);
if (address.isEmpty) {
return null;
}
Expand All @@ -76,16 +81,23 @@ class ServerRepository {
try {
final (info, serverType, resolvedUrl) = await _probeServer(candidate);
final serverAddress = resolvedUrl.isNotEmpty ? resolvedUrl : candidate;
final displayAddress = serverDisplayAddress(
enteredAddress: enteredAddress,
resolvedAddress: serverAddress,
);

final existingIndex = _servers.indexWhere(
(s) =>
s.address == serverAddress ||
_endpointIdentity(s.address) == _endpointIdentity(serverAddress),
s.connectionAddress == serverAddress ||
_endpointIdentity(s.connectionAddress) ==
_endpointIdentity(serverAddress),
);
if (existingIndex >= 0) {
final existing = _servers[existingIndex];
final updated = existing.copyWith(
name: info['ServerName'] as String? ?? existing.name,
address: displayAddress,
connectionAddress: serverAddress,
version: info['Version'] as String? ?? existing.version,
serverType: serverType,
dateLastAccessed: DateTime.now(),
Expand All @@ -100,8 +112,9 @@ class ServerRepository {

final server = Server(
id: const Uuid().v4(),
name: info['ServerName'] as String? ?? address,
address: serverAddress,
name: info['ServerName'] as String? ?? displayAddress,
address: displayAddress,
connectionAddress: serverAddress,
version: info['Version'] as String? ?? '',
serverType: serverType,
loginDisclaimer: info['LoginDisclaimer'] as String?,
Expand Down Expand Up @@ -171,7 +184,9 @@ class ServerRepository {
try {
final result = await probeServerPublicInfo(dio, baseUrl);
if (result == null) {
throw const FormatException('No Jellyfin or Emby server at this address');
throw const FormatException(
'No Jellyfin or Emby server at this address',
);
}
return (
result.info,
Expand Down
2 changes: 1 addition & 1 deletion lib/auth/repositories/server_user_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class ServerUserRepository {
Future<List<PublicUser>> getPublicServerUsers(Server server) async {
final dio = Dio(
BaseOptions(
baseUrl: server.address,
baseUrl: server.connectionAddress,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
Expand Down
49 changes: 49 additions & 0 deletions lib/util/server_url.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,55 @@ String normalizeServerBaseUrl(String input) {
);
}

/// Keeps an IDN hostname in the Unicode form the user entered while retaining
/// the resolved scheme, port, and path used for the connection.
///
/// Explicit Punycode input is never decoded. If the resolved endpoint uses a
/// different hostname, the resolved address is returned unchanged.
String serverDisplayAddress({
required String enteredAddress,
required String resolvedAddress,
}) {
final entered = enteredAddress.trim();
if (entered.isEmpty || resolvedAddress.isEmpty) return resolvedAddress;

final enteredHasScheme = _schemeRegex.hasMatch(entered);
final enteredUri = Uri.tryParse(
enteredHasScheme ? entered : 'https://$entered',
);
final resolvedUri = Uri.tryParse(resolvedAddress);
if (enteredUri == null ||
enteredUri.host.isEmpty ||
resolvedUri == null ||
resolvedUri.host.isEmpty) {
return resolvedAddress;
}

String enteredHost;
try {
enteredHost = Uri.decodeComponent(enteredUri.host);
} catch (_) {
return resolvedAddress;
}

if (!enteredHost.runes.any((rune) => rune > 0x7f)) {
return resolvedAddress;
}

String enteredAscii;
try {
enteredAscii = domainToAscii(enteredHost).toLowerCase();
} on FormatException {
return resolvedAddress;
}

if (enteredAscii != resolvedUri.host.toLowerCase()) {
return resolvedAddress;
}

return resolvedAddress.replaceFirst(resolvedUri.host, enteredHost);
}

String _normalizeServerHost(String host) {
if (host.isEmpty) return host;

Expand Down
38 changes: 38 additions & 0 deletions test/server_model_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonfin/auth/models/server.dart';
import 'package:server_core/server_core.dart';

void main() {
test('stores display and connection addresses separately', () {
final server = Server(
id: 'server-1',
name: 'Books',
address: 'https://bücher.de',
connectionAddress: 'https://xn--bcher-kva.de',
version: '1.0.0',
serverType: ServerType.jellyfin,
dateAdded: DateTime.utc(2026, 8, 20),
);

final json = server.toJson();
expect(json['address'], 'https://bücher.de');
expect(json['connectionAddress'], 'https://xn--bcher-kva.de');

final restored = Server.fromJson(server.id, json);
expect(restored.address, 'https://bücher.de');
expect(restored.connectionAddress, 'https://xn--bcher-kva.de');
});

test('legacy stored servers use address as the connection address', () {
final restored = Server.fromJson('legacy', {
'name': 'Legacy',
'address': 'https://example.com',
'version': '1.0.0',
'serverType': ServerType.jellyfin.name,
'dateAdded': DateTime.utc(2026, 8, 20).toIso8601String(),
});

expect(restored.address, 'https://example.com');
expect(restored.connectionAddress, 'https://example.com');
});
}
42 changes: 39 additions & 3 deletions test/util/server_url_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@ void main() {
);
});

// Punycode is what the user sees as well as what we store. This one
// decodes to a Cyrillic lookalike of apple.com, and the address sits on
// the screen where someone picks which server to sign in to.
test('leaves a Punycode hostname encoded', () {
expect(
normalizeServerBaseUrl('https://xn--80ak6aa92e.com'),
Expand Down Expand Up @@ -89,4 +86,43 @@ void main() {
);
});
});

group('serverDisplayAddress', () {
test('preserves a Unicode hostname entered by the user', () {
expect(
serverDisplayAddress(
enteredAddress: 'https://bücher.de',
resolvedAddress: 'https://xn--bcher-kva.de',
),
'https://bücher.de',
);
expect(
serverDisplayAddress(
enteredAddress: 'media.bücher.de',
resolvedAddress: 'https://media.xn--bcher-kva.de:8443/jellyfin',
),
'https://media.bücher.de:8443/jellyfin',
);
});

test('does not decode explicitly entered Punycode', () {
expect(
serverDisplayAddress(
enteredAddress: 'https://xn--80ak6aa92e.com',
resolvedAddress: 'https://xn--80ak6aa92e.com',
),
'https://xn--80ak6aa92e.com',
);
});

test('does not carry an entered hostname across a redirect', () {
expect(
serverDisplayAddress(
enteredAddress: 'https://bücher.de',
resolvedAddress: 'https://example.com',
),
'https://example.com',
);
});
});
}
Loading