diff --git a/docker/.env.example b/docker/.env.example index 5af93a3ba6..922a873c05 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -76,3 +76,14 @@ PUBLIC_LOGIN_PAGE_MESSAGE= IMMICH_WEB_URL=http://immich-web:3000 IMMICH_SERVER_URL=http://immich-server:3001 IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 + +#################################################################################### +# Alternative API's External Address - Optional +# +# This is an advanced feature used to control the public server endpoint returned to clients during Well-known discovery. +# You should only use this if you want mobile apps to access the immich API over a custom URL. Do not include trailing slash. +# NOTE: At this time, the web app will not be affected by this setting and will continue to use the relative path: /api +# Examples: http://localhost:3001, http://immich-api.example.com, etc +#################################################################################### + +#IMMICH_API_URL_EXTERNAL=http://localhost:3001 \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index cb219ef43f..fde871daff 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: immich-server: @@ -14,6 +14,7 @@ services: - ${UPLOAD_LOCATION}:/usr/src/app/upload - /usr/src/app/node_modules ports: + - 3001:3001 - 9230:9230 env_file: - .env @@ -75,6 +76,7 @@ services: environment: # Rename these values for svelte public interface - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} + - PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL} ports: - 3000:3000 - 24678:24678 diff --git a/docker/docker-compose.staging.yml b/docker/docker-compose.staging.yml index 153b3c9cd7..e2bc244045 100644 --- a/docker/docker-compose.staging.yml +++ b/docker/docker-compose.staging.yml @@ -54,6 +54,7 @@ services: environment: # Rename these values for svelte public interface - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} + - PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL} restart: always redis: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 150e498f08..bc6f4698bc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -54,6 +54,7 @@ services: environment: # Rename these values for svelte public interface - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} + - PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL} restart: always redis: diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 463a3f0c3b..49edbdcf73 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -114,10 +114,10 @@ "library_page_new_album": "New album", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port/", "login_form_endpoint_url": "Server Endpoint URL", - "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", "login_form_err_leading_whitespace": "Leading whitespace", "login_form_err_trailing_whitespace": "Trailing whitespace", "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index c05341627a..1e3e120851 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -357,7 +357,6 @@ class BackgroundService { Hive.openBox(hiveBackupInfoBox), ]); ApiService apiService = ApiService(); - apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); BackupService backupService = BackupService(apiService); AppSettingsService settingsService = AppSettingsService(); diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 9bf7d53eba..ed83595295 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -54,20 +54,12 @@ class AuthenticationNotifier extends StateNotifier { Future login( String email, String password, - String serverEndpoint, + String serverUrl, bool isSavedLoginInfo, ) async { - // Store server endpoint to Hive and test endpoint - if (serverEndpoint[serverEndpoint.length - 1] == "/") { - var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1); - Hive.box(userInfoBox).put(serverEndpointKey, validUrl); - } else { - Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint); - } - - // Check Server URL validity try { - _apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); + // Resolve API server endpoint from user provided serverUrl + await _apiService.resolveAndSetEndpoint(serverUrl); await _apiService.serverInfoApi.pingServer(); } catch (e) { debugPrint('Invalid Server Endpoint Url $e'); @@ -90,7 +82,7 @@ class AuthenticationNotifier extends StateNotifier { return setSuccessLoginInfo( accessToken: loginResponse.accessToken, - serverUrl: serverEndpoint, + serverUrl: serverUrl, isSavedLoginInfo: isSavedLoginInfo, ); } catch (e) { @@ -174,7 +166,6 @@ class AuthenticationNotifier extends StateNotifier { var deviceInfo = await _deviceInfoService.getDeviceInfo(); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(accessTokenKey, accessToken); - userInfoHiveBox.put(serverEndpointKey, serverUrl); state = state.copyWith( isAuthenticated: true, diff --git a/mobile/lib/modules/login/services/oauth.service.dart b/mobile/lib/modules/login/services/oauth.service.dart index 995aef2757..f056898f36 100644 --- a/mobile/lib/modules/login/services/oauth.service.dart +++ b/mobile/lib/modules/login/services/oauth.service.dart @@ -11,9 +11,10 @@ class OAuthService { OAuthService(this._apiService); Future getOAuthServerConfig( - String serverEndpoint, + String serverUrl, ) async { - _apiService.setEndpoint(serverEndpoint); + // Resolve API server endpoint from user provided serverUrl + await _apiService.resolveAndSetEndpoint(serverUrl); return await _apiService.oAuthApi.generateConfig( OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'), diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index f9c5368347..a355527523 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:openapi/api.dart'; class LoginForm extends HookConsumerWidget { @@ -25,7 +26,7 @@ class LoginForm extends HookConsumerWidget { final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = - useTextEditingController(text: 'login_form_endpoint_hint'.tr()); + useTextEditingController.fromValue(TextEditingValue.empty); final apiService = ref.watch(apiServiceProvider); final serverEndpointFocusNode = useFocusNode(); final isSaveLoginInfo = useState(false); @@ -35,16 +36,16 @@ class LoginForm extends HookConsumerWidget { getServeLoginConfig() async { if (!serverEndpointFocusNode.hasFocus) { - var urlText = serverEndpointController.text.trim(); + var serverUrl = serverEndpointController.text.trim(); try { - var endpointUrl = Uri.tryParse(urlText); - - if (endpointUrl != null) { + if (serverUrl.isNotEmpty) { isLoading.value = true; - apiService.setEndpoint(endpointUrl.toString()); + final serverEndpoint = + await apiService.resolveAndSetEndpoint(serverUrl.toString()); + var loginConfig = await apiService.oAuthApi.generateConfig( - OAuthConfigDto(redirectUri: endpointUrl.toString()), + OAuthConfigDto(redirectUri: serverEndpoint), ); if (loginConfig != null) { @@ -213,11 +214,16 @@ class ServerEndpointInput extends StatelessWidget { }) : super(key: key); String? _validateInput(String? url) { - if (url?.startsWith(RegExp(r'https?://')) == true) { - return null; - } else { - return 'login_form_err_http'.tr(); + if (url == null || url.isEmpty) return null; + + final parsedUrl = Uri.tryParse(sanitizeUrl(url)); + if (parsedUrl == null || + !parsedUrl.isAbsolute || + !parsedUrl.scheme.startsWith("http") || + parsedUrl.host.isEmpty) { + return 'login_form_err_invalid_url'.tr(); } + return null; } @override diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index e9f8db40ea..913f71ff9a 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -58,14 +58,15 @@ class WebsocketNotifier extends StateNotifier { if (authenticationState.isAuthenticated) { var accessToken = Hive.box(userInfoBox).get(accessTokenKey); - var endpoint = Hive.box(userInfoBox).get(serverEndpointKey); try { + var endpoint = Uri.parse(Hive.box(userInfoBox).get(serverEndpointKey)); + debugPrint("Attempting to connect to websocket"); // Configure socket transports must be specified Socket socket = io( - endpoint.toString().replaceAll('/api', ''), + endpoint.origin, OptionBuilder() - .setPath('/api/socket.io') + .setPath("${endpoint.path}/socket.io") .setTransports(['websocket']) .enableReconnection() .enableForceNew() diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 900e261e3a..3251883e72 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -1,4 +1,11 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:openapi/api.dart'; +import 'package:http/http.dart'; class ApiService { late ApiClient _apiClient; @@ -11,6 +18,17 @@ class ApiService { late ServerInfoApi serverInfoApi; late DeviceInfoApi deviceInfoApi; + ApiService() { + if (Hive.isBoxOpen(userInfoBox)) { + final endpoint = Hive.box(userInfoBox).get(serverEndpointKey) as String; + if (endpoint.isNotEmpty) { + setEndpoint(endpoint); + } + } else { + debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet."); + } + } + setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint); userApi = UserApi(_apiClient); @@ -22,6 +40,59 @@ class ApiService { deviceInfoApi = DeviceInfoApi(_apiClient); } + Future resolveAndSetEndpoint(String serverUrl) async { + final endpoint = await _resolveEndpoint(serverUrl); + setEndpoint(endpoint); + + // Save in hivebox for next startup + Hive.box(userInfoBox).put(serverEndpointKey, endpoint); + return endpoint; + } + + /// Takes a server URL and attempts to resolve the API endpoint. + /// + /// Input: [schema://]host[:port][/path] + /// schema - optional (default: https) + /// host - required + /// port - optional (default: based on schema) + /// path - optional + Future _resolveEndpoint(String serverUrl) async { + final url = sanitizeUrl(serverUrl); + + // Check for /.well-known/immich + final wellKnownEndpoint = await _getWellKnownEndpoint(url); + if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint; + + // Otherwise, assume the URL provided is the api endpoint + return url; + } + + Future _getWellKnownEndpoint(String baseUrl) async { + final Client client = Client(); + + try { + final res = await client.get( + Uri.parse("$baseUrl/.well-known/immich"), + headers: {"Accept": "application/json"}, + ); + + if (res.statusCode == 200) { + final data = jsonDecode(res.body); + final endpoint = data['api']['endpoint'].toString(); + + if (endpoint.startsWith('/')) { + // Full URL is relative to base + return "$baseUrl$endpoint"; + } + return endpoint; + } + } catch (e) { + debugPrint("Could not locate /.well-known/immich at $baseUrl"); + } + + return ""; + } + setAccessToken(String accessToken) { _apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken'); } diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index e659c1241a..7aa973fcf6 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -22,8 +22,8 @@ class SplashScreenPage extends HookConsumerWidget { void performLoggingIn() async { try { if (loginInfo != null) { - // Make sure API service is initialized - apiService.setEndpoint(loginInfo.serverUrl); + // Resolve API server endpoint from user provided serverUrl + await apiService.resolveAndSetEndpoint(loginInfo.serverUrl); var isSuccess = await ref .read(authenticationProvider.notifier) diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart new file mode 100644 index 0000000000..66ce723a95 --- /dev/null +++ b/mobile/lib/utils/url_helper.dart @@ -0,0 +1,8 @@ +String sanitizeUrl(String url) { + // Add schema if none is set + final urlWithSchema = + url.startsWith(RegExp(r"https?://")) ? url : "https://$url"; + + // Remove trailing slash(es) + return urlWithSchema.replaceFirst(RegExp(r"/+$"), ""); +} diff --git a/web/src/api/api.ts b/web/src/api/api.ts index ed21ab7570..011287d161 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -56,7 +56,10 @@ class ImmichApi { } } +// Browser side (public) API client export const api = new ImmichApi(); + +// Server side API client export const serverApi = new ImmichApi(); const immich_server_url = env.PUBLIC_IMMICH_SERVER_URL || 'http://immich-server:3001'; serverApi.setBaseUrl(immich_server_url); diff --git a/web/src/routes/.well-known/immich/+server.ts b/web/src/routes/.well-known/immich/+server.ts new file mode 100644 index 0000000000..7b9b0068ad --- /dev/null +++ b/web/src/routes/.well-known/immich/+server.ts @@ -0,0 +1,12 @@ +import { env } from '$env/dynamic/public'; +import { json } from '@sveltejs/kit'; + +const endpoint = env.PUBLIC_IMMICH_API_URL_EXTERNAL || '/api'; + +export const GET = async () => { + return json({ + api: { + endpoint + } + }); +};