You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(.well-known): add .well-known/immich to reference API endpoint (#1308)
* feat(.well-known): add .well-known/immich to reference API endpoint
* feat(.well-known): make schema optional (defaults to https)
* adjust method comment to be a little less confusing
* fix casting issue with resovled url
* include when checking Well-known, update server hint
* add validation for login form's server url
* consolidate common process into resolveAndSetEndpoint
* fix missed prettier formatting
* revert translation changes
* update environment variable description, hopefully a bit clearer
* rename environment variable to IMMICH_API_URL_EXTERNAL
* comment out optional env variables
* fix(web): browser-side api client to include authorization token
* Revert "fix(web): browser-side api client to include authorization token"
This reverts commit 60e338938f.
* remove multi-domain related changes
			
			
This commit is contained in:
		| @@ -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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -357,7 +357,6 @@ class BackgroundService { | ||||
|       Hive.openBox<HiveBackupAlbums>(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(); | ||||
|   | ||||
| @@ -54,20 +54,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|   Future<bool> 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<AuthenticationState> { | ||||
|  | ||||
|       return setSuccessLoginInfo( | ||||
|         accessToken: loginResponse.accessToken, | ||||
|         serverUrl: serverEndpoint, | ||||
|         serverUrl: serverUrl, | ||||
|         isSavedLoginInfo: isSavedLoginInfo, | ||||
|       ); | ||||
|     } catch (e) { | ||||
| @@ -174,7 +166,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|       var deviceInfo = await _deviceInfoService.getDeviceInfo(); | ||||
|       userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); | ||||
|       userInfoHiveBox.put(accessTokenKey, accessToken); | ||||
|       userInfoHiveBox.put(serverEndpointKey, serverUrl); | ||||
|  | ||||
|       state = state.copyWith( | ||||
|         isAuthenticated: true, | ||||
|   | ||||
| @@ -11,9 +11,10 @@ class OAuthService { | ||||
|   OAuthService(this._apiService); | ||||
|  | ||||
|   Future<OAuthConfigResponseDto?> 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:/'), | ||||
|   | ||||
| @@ -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<bool>(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 | ||||
|   | ||||
| @@ -58,14 +58,15 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|  | ||||
|     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() | ||||
|   | ||||
| @@ -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<String> 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<String> _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<String> _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'); | ||||
|   } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										8
									
								
								mobile/lib/utils/url_helper.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								mobile/lib/utils/url_helper.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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"/+$"), ""); | ||||
| } | ||||
| @@ -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); | ||||
|   | ||||
							
								
								
									
										12
									
								
								web/src/routes/.well-known/immich/+server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/src/routes/.well-known/immich/+server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user