Selaa lähdekoodia

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 60e338938f25792adb233d35bcecbd789bdb3240.

* remove multi-domain related changes
Connery Noble 2 vuotta sitten
vanhempi
commit
43e9529ce4

+ 11 - 0
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

+ 3 - 1
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

+ 1 - 0
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:

+ 1 - 0
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:

+ 2 - 2
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",

+ 0 - 1
mobile/lib/modules/backup/background_service/background.service.dart

@@ -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();

+ 4 - 13
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -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,

+ 3 - 2
mobile/lib/modules/login/services/oauth.service.dart

@@ -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:/'),

+ 17 - 11
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<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

+ 4 - 3
mobile/lib/shared/providers/websocket.provider.dart

@@ -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()

+ 71 - 0
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<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');
   }

+ 2 - 2
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)

+ 8 - 0
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"/+$"), "");
+}

+ 3 - 0
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);

+ 12 - 0
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
+		}
+	});
+};