From 6962df63408fb12fb3f82afb695f559776f0d556 Mon Sep 17 00:00:00 2001 From: Connery Noble Date: Wed, 11 Jan 2023 17:30:01 -0800 Subject: [PATCH] feat(.well-known): add .well-known/immich to reference API endpoint --- docker/.env.example | 18 ++++++++ docker/docker-compose.dev.yml | 2 + docker/docker-compose.staging.yml | 1 + docker/docker-compose.yml | 1 + .../providers/authentication.provider.dart | 19 +++----- .../modules/login/services/oauth.service.dart | 4 +- mobile/lib/modules/login/ui/login_form.dart | 12 +++-- .../shared/providers/websocket.provider.dart | 7 +-- mobile/lib/shared/services/api.service.dart | 45 +++++++++++++++++++ mobile/lib/shared/views/splash_screen.dart | 7 ++- server/apps/immich/src/main.ts | 4 ++ server/libs/common/src/config/app.config.ts | 1 + web/src/api/api.ts | 8 ++++ web/src/routes/.well-known/immich/+server.ts | 13 ++++++ 14 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 web/src/routes/.well-known/immich/+server.ts diff --git a/docker/.env.example b/docker/.env.example index 5af93a3ba6..ecafac1f3c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -76,3 +76,21 @@ 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 + +#################################################################################### +# Public Facing API Server URL - Optional +# +# This should point to the publicly accessible URL of the API (default is "/api") +# Use a full URL here if you want to serve the API via a dedicated domain. +#################################################################################### + +IMMICH_SERVER_PUBLIC_URL=/api + +#################################################################################### +# API Cors Allowed Origins - Optional +# +# This is required if the API is served from a different domain than the website. +# For example, if IMMICH_SERVER_PUBLIC_URL is set to a full URL. +#################################################################################### + +ALLOW_CORS_ORIGIN=http://localhost:2283 \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index cb219ef43f..54e8bb9e14 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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_SERVER_PUBLIC_URL=${IMMICH_SERVER_PUBLIC_URL} ports: - 3000:3000 - 24678:24678 diff --git a/docker/docker-compose.staging.yml b/docker/docker-compose.staging.yml index 153b3c9cd7..e70d8b5443 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_SERVER_PUBLIC_URL=${IMMICH_SERVER_PUBLIC_URL} restart: always redis: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 150e498f08..27511d4340 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_SERVER_PUBLIC_URL=${IMMICH_SERVER_PUBLIC_URL} restart: always redis: diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 9bf7d53eba..6c02fd98a1 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -54,21 +54,15 @@ 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 + final serverEndpoint = await _apiService.resolveEndpoint(serverUrl); + _apiService.setEndpoint(serverEndpoint); await _apiService.serverInfoApi.pingServer(); + Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint); } catch (e) { debugPrint('Invalid Server Endpoint Url $e'); return false; @@ -90,7 +84,7 @@ class AuthenticationNotifier extends StateNotifier { return setSuccessLoginInfo( accessToken: loginResponse.accessToken, - serverUrl: serverEndpoint, + serverUrl: serverUrl, isSavedLoginInfo: isSavedLoginInfo, ); } catch (e) { @@ -174,7 +168,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..688164162a 100644 --- a/mobile/lib/modules/login/services/oauth.service.dart +++ b/mobile/lib/modules/login/services/oauth.service.dart @@ -11,8 +11,10 @@ class OAuthService { OAuthService(this._apiService); Future getOAuthServerConfig( - String serverEndpoint, + String serverUrl, ) async { + // Resolve API server endpoint from user provided serverUrl + final serverEndpoint = await _apiService.resolveEndpoint(serverUrl); _apiService.setEndpoint(serverEndpoint); return await _apiService.oAuthApi.generateConfig( diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index f9c5368347..4819187b63 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -38,13 +38,17 @@ class LoginForm extends HookConsumerWidget { var urlText = serverEndpointController.text.trim(); try { - var endpointUrl = Uri.tryParse(urlText); + var serverUrl = Uri.tryParse(urlText); - if (endpointUrl != null) { + if (serverUrl != null) { isLoading.value = true; - apiService.setEndpoint(endpointUrl.toString()); + final serverEndpoint = + await apiService.resolveEndpoint(serverUrl.toString()); + apiService.setEndpoint(serverEndpoint); + Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint); + var loginConfig = await apiService.oAuthApi.generateConfig( - OAuthConfigDto(redirectUri: endpointUrl.toString()), + OAuthConfigDto(redirectUri: serverEndpoint), ); if (loginConfig != null) { 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..f93f1159ab 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; import 'package:openapi/api.dart'; +import 'package:http/http.dart'; class ApiService { late ApiClient _apiClient; @@ -22,6 +26,47 @@ class ApiService { deviceInfoApi = DeviceInfoApi(_apiClient); } + resolveEndpoint(String serverUrl) async { + // Sanitize URL to only include origin+path + final url = Uri.parse(serverUrl); + final baseUrl = "${url.origin}${url.path}"; + + // Remove trailing slash, if exists + final endpoint = baseUrl[baseUrl.length - 1] == "/" + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + + // Check for .well-known definition, otherwise assume endpoint is full API address + final apiEndpoint = await getWellKnownEndpoint(endpoint) ?? endpoint; + return apiEndpoint; + } + + 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'] as String; + + if (endpoint.startsWith('/')) { + // Full URL is relative to base + return "$baseUrl$endpoint"; + } + return endpoint; + } + } catch (e) { + debugPrint("Could not locate .well-known at $baseUrl: $e"); + } + + return null; + } + 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..37c009d50d 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -22,8 +22,11 @@ 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 + final serverEndpoint = + await apiService.resolveEndpoint(loginInfo.serverUrl); + apiService.setEndpoint(serverEndpoint); + Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint); var isSuccess = await ref .read(authenticationProvider.notifier) diff --git a/server/apps/immich/src/main.ts b/server/apps/immich/src/main.ts index 18d92c24ef..e98b98c443 100644 --- a/server/apps/immich/src/main.ts +++ b/server/apps/immich/src/main.ts @@ -25,6 +25,10 @@ async function bootstrap() { app.use(json({ limit: '10mb' })); if (process.env.NODE_ENV === 'development') { app.enableCors(); + } else if (process.env.ALLOW_CORS_ORIGIN) { + app.enableCors({ + origin: process.env.ALLOW_CORS_ORIGIN + }); } const redisIoAdapter = new RedisIoAdapter(app); diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index 5e79c4260b..adfe152cff 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -28,5 +28,6 @@ export const immichAppConfig: ConfigModuleOptions = { DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), + ALLOW_CORS_ORIGIN: Joi.string().optional(), }), }; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index ed21ab7570..9b41708c9d 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -54,9 +54,17 @@ class ImmichApi { public setBaseUrl(baseUrl: string) { this.config.basePath = baseUrl; } + + public getBaseUrl() { + return this.config.basePath; + } } +// Browser side (public) API client export const api = new ImmichApi(); +api.setBaseUrl(env.PUBLIC_IMMICH_SERVER_PUBLIC_URL || '/api'); + +// 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..ea9c44cef9 --- /dev/null +++ b/web/src/routes/.well-known/immich/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import { api } from '@api'; + +const endpoint = api.getBaseUrl(); + +export const prerender = true; +export const GET = async () => { + return json({ + api: { + endpoint + } + }); +};