From a0a0aa3f3c1ecfdd38c2d0566d433a6042c8a95e Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 23 Jul 2026 07:16:48 -0700 Subject: [PATCH] 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 --- mobile/lib/services/api.service.dart | 4 +-- mobile/lib/utils/url_helper.dart | 25 ++++++++++++-- .../lib/widgets/forms/login/login_form.dart | 19 +++-------- .../test/modules/utils/url_helper_test.dart | 34 +++++++++++++++++++ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 59ef935f2f..ab05ffc18f 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -96,12 +96,12 @@ class ApiService { /// port - optional (default: based on schema) /// path - optional Future 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)) { diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index b7dc41c4cf..3bcdca06f3 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -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() { diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 17e5afe262..79617f8fe4 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -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 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, ), diff --git a/mobile/test/modules/utils/url_helper_test.dart b/mobile/test/modules/utils/url_helper_test.dart index 0e8a8e2aa0..245f68e767 100644 --- a/mobile/test/modules/utils/url_helper_test.dart +++ b/mobile/test/modules/utils/url_helper_test.dart @@ -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);