fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461)

This commit is contained in:
Gagan Yadav 2025-04-08 21:34:42 +05:30 committed by GitHub
parent e5ca79dd44
commit ac65d46ec6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 218 additions and 9 deletions

View file

@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
// Add schema if none is set
@ -11,13 +12,80 @@ String sanitizeUrl(String url) {
}
String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
}
return serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}";
return Uri.decodeFull(
serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}",
);
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
///
/// This is especially useful for internationalized domain names (IDNs),
/// where parts of the URL (typically the host) contain non-ASCII characters.
///
/// Example:
/// ```dart
/// final encodedUrl = punycodeEncodeUrl('https://bücher.de');
/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de
/// ```
///
/// Notes:
/// - If the input URL is invalid, an empty string is returned.
/// - Only the host part of the URL is converted to Punycode; the scheme,
/// path, and port remain unchanged.
///
String punycodeEncodeUrl(String serverUrl) {
final serverUri = Uri.tryParse(serverUrl);
if (serverUri == null || serverUri.host.isEmpty) return '';
final encodedHost = Uri.decodeComponent(serverUri.host).split('.').map(
(segment) {
// If segment is already ASCII, then return as it is.
if (segment.runes.every((c) => c < 0x80)) return segment;
return 'xn--${punycodeEncode(segment)}';
},
).join('.');
return serverUri.replace(host: encodedHost).toString();
}
/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation.
///
/// This method is useful for converting internationalized domain names (IDNs)
/// that were previously encoded with Punycode back to their human-readable Unicode form.
///
/// Example:
/// ```dart
/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de');
/// print(decodedUrl); // Outputs: https://bücher.de
/// ```
///
/// Notes:
/// - If the input URL is invalid the method returns `null`.
/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved.
/// - The method assumes that the input URL only contains: scheme, host, port (optional).
/// - Query parameters, fragments, and user info are not handled (by design, as per constraints).
///
String? punycodeDecodeUrl(String? serverUrl) {
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null || serverUri.host.isEmpty) return null;
final decodedHost = serverUri.host.split('.').map(
(segment) {
if (segment.toLowerCase().startsWith('xn--')) {
return punycodeDecode(segment.substring(4));
}
// If segment is not punycode encoded, then return as it is.
return segment;
},
).join('.');
return Uri.decodeFull(serverUri.replace(host: decodedHost).toString());
}

View file

@ -1,4 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -7,18 +8,18 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
@ -82,7 +83,8 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final serverUrl = sanitizeUrl(serverEndpointController.text);
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
if (serverUrl.isEmpty) {