mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461)
This commit is contained in:
parent
e5ca79dd44
commit
ac65d46ec6
@ -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());
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
138
mobile/test/modules/utils/url_helper_test.dart
Normal file
138
mobile/test/modules/utils/url_helper_test.dart
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user