1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00: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:
Connery Noble 2023-01-19 07:45:37 -08:00 committed by GitHub
parent 0c258f0506
commit 43e9529ce4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 142 additions and 35 deletions

View file

@ -76,3 +76,14 @@ PUBLIC_LOGIN_PAGE_MESSAGE=
IMMICH_WEB_URL=http://immich-web:3000 IMMICH_WEB_URL=http://immich-web:3000
IMMICH_SERVER_URL=http://immich-server:3001 IMMICH_SERVER_URL=http://immich-server:3001
IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 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

View file

@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
immich-server: immich-server:
@ -14,6 +14,7 @@ services:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules - /usr/src/app/node_modules
ports: ports:
- 3001:3001
- 9230:9230 - 9230:9230
env_file: env_file:
- .env - .env
@ -75,6 +76,7 @@ services:
environment: environment:
# Rename these values for svelte public interface # Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
ports: ports:
- 3000:3000 - 3000:3000
- 24678:24678 - 24678:24678

View file

@ -54,6 +54,7 @@ services:
environment: environment:
# Rename these values for svelte public interface # Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
restart: always restart: always
redis: redis:

View file

@ -54,6 +54,7 @@ services:
environment: environment:
# Rename these values for svelte public interface # Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL} - PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
restart: always restart: always
redis: redis:

View file

@ -114,10 +114,10 @@
"library_page_new_album": "New album", "library_page_new_album": "New album",
"login_form_button_text": "Login", "login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com", "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_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_email": "Invalid Email",
"login_form_err_invalid_url": "Invalid URL",
"login_form_err_leading_whitespace": "Leading whitespace", "login_form_err_leading_whitespace": "Leading whitespace",
"login_form_err_trailing_whitespace": "Trailing whitespace", "login_form_err_trailing_whitespace": "Trailing whitespace",
"login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",

View file

@ -357,7 +357,6 @@ class BackgroundService {
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
]); ]);
ApiService apiService = ApiService(); ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService); BackupService backupService = BackupService(apiService);
AppSettingsService settingsService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService();

View file

@ -54,20 +54,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> login( Future<bool> login(
String email, String email,
String password, String password,
String serverEndpoint, String serverUrl,
bool isSavedLoginInfo, bool isSavedLoginInfo,
) async { ) 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 { try {
_apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); // Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
await _apiService.serverInfoApi.pingServer(); await _apiService.serverInfoApi.pingServer();
} catch (e) { } catch (e) {
debugPrint('Invalid Server Endpoint Url $e'); debugPrint('Invalid Server Endpoint Url $e');
@ -90,7 +82,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
return setSuccessLoginInfo( return setSuccessLoginInfo(
accessToken: loginResponse.accessToken, accessToken: loginResponse.accessToken,
serverUrl: serverEndpoint, serverUrl: serverUrl,
isSavedLoginInfo: isSavedLoginInfo, isSavedLoginInfo: isSavedLoginInfo,
); );
} catch (e) { } catch (e) {
@ -174,7 +166,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken); userInfoHiveBox.put(accessTokenKey, accessToken);
userInfoHiveBox.put(serverEndpointKey, serverUrl);
state = state.copyWith( state = state.copyWith(
isAuthenticated: true, isAuthenticated: true,

View file

@ -11,9 +11,10 @@ class OAuthService {
OAuthService(this._apiService); OAuthService(this._apiService);
Future<OAuthConfigResponseDto?> getOAuthServerConfig( Future<OAuthConfigResponseDto?> getOAuthServerConfig(
String serverEndpoint, String serverUrl,
) async { ) async {
_apiService.setEndpoint(serverEndpoint); // Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
return await _apiService.oAuthApi.generateConfig( return await _apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'), OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),

View file

@ -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/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.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/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
@ -25,7 +26,7 @@ class LoginForm extends HookConsumerWidget {
final passwordController = final passwordController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = final serverEndpointController =
useTextEditingController(text: 'login_form_endpoint_hint'.tr()); useTextEditingController.fromValue(TextEditingValue.empty);
final apiService = ref.watch(apiServiceProvider); final apiService = ref.watch(apiServiceProvider);
final serverEndpointFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode();
final isSaveLoginInfo = useState<bool>(false); final isSaveLoginInfo = useState<bool>(false);
@ -35,16 +36,16 @@ class LoginForm extends HookConsumerWidget {
getServeLoginConfig() async { getServeLoginConfig() async {
if (!serverEndpointFocusNode.hasFocus) { if (!serverEndpointFocusNode.hasFocus) {
var urlText = serverEndpointController.text.trim(); var serverUrl = serverEndpointController.text.trim();
try { try {
var endpointUrl = Uri.tryParse(urlText); if (serverUrl.isNotEmpty) {
if (endpointUrl != null) {
isLoading.value = true; isLoading.value = true;
apiService.setEndpoint(endpointUrl.toString()); final serverEndpoint =
await apiService.resolveAndSetEndpoint(serverUrl.toString());
var loginConfig = await apiService.oAuthApi.generateConfig( var loginConfig = await apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: endpointUrl.toString()), OAuthConfigDto(redirectUri: serverEndpoint),
); );
if (loginConfig != null) { if (loginConfig != null) {
@ -213,11 +214,16 @@ class ServerEndpointInput extends StatelessWidget {
}) : super(key: key); }) : super(key: key);
String? _validateInput(String? url) { String? _validateInput(String? url) {
if (url?.startsWith(RegExp(r'https?://')) == true) { if (url == null || url.isEmpty) return null;
return null;
} else { final parsedUrl = Uri.tryParse(sanitizeUrl(url));
return 'login_form_err_http'.tr(); if (parsedUrl == null ||
!parsedUrl.isAbsolute ||
!parsedUrl.scheme.startsWith("http") ||
parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
} }
return null;
} }
@override @override

View file

@ -58,14 +58,15 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) { if (authenticationState.isAuthenticated) {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey); var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try { try {
var endpoint = Uri.parse(Hive.box(userInfoBox).get(serverEndpointKey));
debugPrint("Attempting to connect to websocket"); debugPrint("Attempting to connect to websocket");
// Configure socket transports must be specified // Configure socket transports must be specified
Socket socket = io( Socket socket = io(
endpoint.toString().replaceAll('/api', ''), endpoint.origin,
OptionBuilder() OptionBuilder()
.setPath('/api/socket.io') .setPath("${endpoint.path}/socket.io")
.setTransports(['websocket']) .setTransports(['websocket'])
.enableReconnection() .enableReconnection()
.enableForceNew() .enableForceNew()

View file

@ -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:openapi/api.dart';
import 'package:http/http.dart';
class ApiService { class ApiService {
late ApiClient _apiClient; late ApiClient _apiClient;
@ -11,6 +18,17 @@ class ApiService {
late ServerInfoApi serverInfoApi; late ServerInfoApi serverInfoApi;
late DeviceInfoApi deviceInfoApi; 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) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint); _apiClient = ApiClient(basePath: endpoint);
userApi = UserApi(_apiClient); userApi = UserApi(_apiClient);
@ -22,6 +40,59 @@ class ApiService {
deviceInfoApi = DeviceInfoApi(_apiClient); 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) { setAccessToken(String accessToken) {
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken'); _apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
} }

View file

@ -22,8 +22,8 @@ class SplashScreenPage extends HookConsumerWidget {
void performLoggingIn() async { void performLoggingIn() async {
try { try {
if (loginInfo != null) { if (loginInfo != null) {
// Make sure API service is initialized // Resolve API server endpoint from user provided serverUrl
apiService.setEndpoint(loginInfo.serverUrl); await apiService.resolveAndSetEndpoint(loginInfo.serverUrl);
var isSuccess = await ref var isSuccess = await ref
.read(authenticationProvider.notifier) .read(authenticationProvider.notifier)

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

View file

@ -56,7 +56,10 @@ class ImmichApi {
} }
} }
// Browser side (public) API client
export const api = new ImmichApi(); export const api = new ImmichApi();
// Server side API client
export const serverApi = new ImmichApi(); export const serverApi = new ImmichApi();
const immich_server_url = env.PUBLIC_IMMICH_SERVER_URL || 'http://immich-server:3001'; const immich_server_url = env.PUBLIC_IMMICH_SERVER_URL || 'http://immich-server:3001';
serverApi.setBaseUrl(immich_server_url); serverApi.setBaseUrl(immich_server_url);

View 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
}
});
};