fix(mobile): allow URL validation to pass when scheme is not provided (#30142)

* fix(mobile): allow URL validation to pass when scheme is not provided

* Do normalize and validate in the same step
This commit is contained in:
Adam Gastineau
2026-07-23 19:46:48 +05:30
committed by GitHub
parent ceef4037f1
commit a0a0aa3f3c
4 changed files with 62 additions and 20 deletions
+2 -2
View File
@@ -96,12 +96,12 @@ class ApiService {
/// port - optional (default: based on schema)
/// path - optional
Future<String> resolveEndpoint(String serverUrl) async {
String url = sanitizeUrl(serverUrl);
String url = normalizeServerUrl(serverUrl);
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) {
url = sanitizeUrl(wellKnownEndpoint);
url = normalizeServerUrl(wellKnownEndpoint);
}
if (!await _isEndpointAvailable(url)) {
+22 -3
View File
@@ -2,12 +2,31 @@ 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) {
/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes
String normalizeServerUrl(String url) {
final trimmedUrl = url.trim();
// Add schema if none is set
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl";
// Remove trailing slash(es)
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
}
/// Validates a user-entered server URL
bool _validateServerUrl(String url) {
final parsedUrl = Uri.tryParse(url);
return parsedUrl != null && parsedUrl.scheme.startsWith(RegExp(r'^https?$')) && parsedUrl.host.isNotEmpty;
}
/// Normalizes and validates that a server URL is supported
bool normalizeAndValidateServerUrl(String? url) {
if (url == null || url.isEmpty) {
return true;
}
final normalizedUrl = normalizeServerUrl(url);
return _validateServerUrl(normalizedUrl);
}
String? getServerUrl() {
+4 -15
View File
@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) {
return null;
}
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
String? _validateEmail(String? email) {
if (email == null || email == '') {
@@ -101,7 +90,7 @@ 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 sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
@@ -304,7 +293,7 @@ class LoginForm extends HookConsumerWidget {
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
sanitizeUrl(serverEndpointController.text),
normalizeServerUrl(serverEndpointController.text),
state,
codeChallenge,
);
@@ -436,7 +425,7 @@ class LoginForm extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
sanitizeUrl(serverEndpointController.text),
normalizeServerUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
@@ -77,6 +77,40 @@ void main() {
});
});
group('normalizeAndValidateServerUrl', () {
test('should treat null as valid', () {
expect(normalizeAndValidateServerUrl(null), isTrue);
});
test('should treat empty string as valid', () {
expect(normalizeAndValidateServerUrl(''), isTrue);
});
test('should accept a bare host', () {
expect(normalizeAndValidateServerUrl('demo.immich.app'), isTrue);
});
test('should accept a bare host with a port', () {
expect(normalizeAndValidateServerUrl('192.168.1.1:2283'), isTrue);
});
test('should accept an http URL', () {
expect(normalizeAndValidateServerUrl('http://demo.immich.app'), isTrue);
});
test('should accept an https URL', () {
expect(normalizeAndValidateServerUrl('https://demo.immich.app:2283/api'), isTrue);
});
test('should reject a non-http scheme', () {
expect(normalizeAndValidateServerUrl('ftp://demo.immich.app'), isFalse);
});
test('should reject scheme only input', () {
expect(normalizeAndValidateServerUrl('https://'), isFalse);
});
});
group('punycodeDecodeUrl', () {
test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull);