diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index 6b355e362f..187026b53c 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -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()); } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index a6da172f0e..ab532987a7 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -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 getServerAuthSettings() async { - final serverUrl = sanitizeUrl(serverEndpointController.text); + final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text); + final serverUrl = punycodeEncodeUrl(sanitizeServerUrl); // Guard empty URL if (serverUrl.isEmpty) { diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e939c65836..73f60d9337 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: permission_handler: ^11.4.0 photo_manager: ^3.6.4 photo_manager_image_provider: ^2.2.0 + punycode: ^1.0.0 riverpod_annotation: ^2.6.1 scrollable_positioned_list: ^0.3.8 share_handler: ^0.0.22 diff --git a/mobile/test/modules/utils/url_helper_test.dart b/mobile/test/modules/utils/url_helper_test.dart new file mode 100644 index 0000000000..840ac91f1f --- /dev/null +++ b/mobile/test/modules/utils/url_helper_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +void main() { + group('punycodeEncodeUrl', () { + test('should return empty string for invalid URL', () { + expect(punycodeEncodeUrl('not a url'), equals('')); + }); + + test('should handle empty input', () { + expect(punycodeEncodeUrl(''), equals('')); + }); + + test('should return ASCII-only URL unchanged', () { + const url = 'https://example.com'; + expect(punycodeEncodeUrl(url), equals(url)); + }); + + test('should encode single-segment Unicode host', () { + const url = 'https://bücher'; + const expected = 'https://xn--bcher-kva'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should encode multi-segment Unicode host', () { + const url = 'https://bücher.de'; + const expected = 'https://xn--bcher-kva.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test( + 'should encode multi-segment Unicode host with multiple non-ASCII segments', + () { + const url = 'https://bücher.münchen'; + const expected = 'https://xn--bcher-kva.xn--mnchen-3ya'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with port', () { + const url = 'https://bücher.de:8080'; + const expected = 'https://xn--bcher-kva.de:8080'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with path', () { + const url = 'https://bücher.de/path/to/resource'; + const expected = 'https://xn--bcher-kva.de/path/to/resource'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with port and path', () { + const url = 'https://bücher.de:3000/path'; + const expected = 'https://xn--bcher-kva.de:3000/path'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should not encode ASCII segment in multi-segment host', () { + const url = 'https://shop.bücher.de'; + const expected = 'https://shop.xn--bcher-kva.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle host with hyphen in Unicode segment', () { + const url = 'https://bü-cher.de'; + const expected = 'https://xn--b-cher-3ya.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle host with numbers in Unicode segment', () { + const url = 'https://bücher123.de'; + const expected = 'https://xn--bcher123-65a.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should encode the domain of the original issue poster :)', () { + const url = 'https://фото.большойчлен.рф/'; + const expected = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/'; + expect(punycodeEncodeUrl(url), expected); + }); + }); + + group('punycodeDecodeUrl', () { + test('should return null for null input', () { + expect(punycodeDecodeUrl(null), isNull); + }); + + test('should return null for an invalid URL', () { + // "not a url" should fail to parse. + expect(punycodeDecodeUrl('not a url'), isNull); + }); + + test('should return null for a URL with empty host', () { + // "https://" is a valid scheme but with no host. + expect(punycodeDecodeUrl('https://'), isNull); + }); + + test('should return ASCII-only URL unchanged', () { + const url = 'https://example.com'; + expect(punycodeDecodeUrl(url), equals(url)); + }); + + test('should decode a single-segment Punycode domain', () { + const input = 'https://xn--bcher-kva.de'; + const expected = 'https://bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode a multi-segment Punycode domain', () { + const input = 'https://shop.xn--bcher-kva.de'; + const expected = 'https://shop.bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode URL with port', () { + const input = 'https://xn--bcher-kva.de:8080'; + const expected = 'https://bücher.de:8080'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode domains with uppercase punycode prefix correctly', () { + const input = 'https://XN--BCHER-KVA.de'; + const expected = 'https://bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should handle mixed segments with no punycode in some parts', () { + const input = 'https://news.xn--bcher-kva.de'; + const expected = 'https://news.bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode the domain of the original issue poster :)', () { + const url = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/'; + const expected = 'https://фото.большойчлен.рф/'; + expect(punycodeDecodeUrl(url), expected); + }); + }); +}