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/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:punycode/punycode.dart';
|
||||||
|
|
||||||
String sanitizeUrl(String url) {
|
String sanitizeUrl(String url) {
|
||||||
// Add schema if none is set
|
// Add schema if none is set
|
||||||
@ -11,13 +12,80 @@ String sanitizeUrl(String url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? getServerUrl() {
|
String? getServerUrl() {
|
||||||
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
|
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
||||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||||
if (serverUri == null) {
|
if (serverUri == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return serverUri.hasPort
|
return Uri.decodeFull(
|
||||||
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
|
serverUri.hasPort
|
||||||
: "${serverUri.scheme}://${serverUri.host}";
|
? "${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 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.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:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.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/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/provider_utils.dart';
|
||||||
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
import 'package:immich_mobile/utils/version_compatibility.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_logo.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.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/email_input.dart';
|
||||||
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
|
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
|
||||||
import 'package:immich_mobile/widgets/forms/login/login_button.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
|
/// Fetch the server login credential and enables oAuth login if necessary
|
||||||
/// Returns true if successful, false otherwise
|
/// Returns true if successful, false otherwise
|
||||||
Future<void> getServerAuthSettings() async {
|
Future<void> getServerAuthSettings() async {
|
||||||
final serverUrl = sanitizeUrl(serverEndpointController.text);
|
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
|
||||||
|
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
|
||||||
|
|
||||||
// Guard empty URL
|
// Guard empty URL
|
||||||
if (serverUrl.isEmpty) {
|
if (serverUrl.isEmpty) {
|
||||||
|
@ -51,6 +51,7 @@ dependencies:
|
|||||||
permission_handler: ^11.4.0
|
permission_handler: ^11.4.0
|
||||||
photo_manager: ^3.6.4
|
photo_manager: ^3.6.4
|
||||||
photo_manager_image_provider: ^2.2.0
|
photo_manager_image_provider: ^2.2.0
|
||||||
|
punycode: ^1.0.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
scrollable_positioned_list: ^0.3.8
|
scrollable_positioned_list: ^0.3.8
|
||||||
share_handler: ^0.0.22
|
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