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
This commit is contained in:
Connery Noble
2023-01-19 07:45:37 -08:00
committed by GitHub
parent 0c258f0506
commit 43e9529ce4
15 changed files with 142 additions and 35 deletions

View File

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

View File

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

View File

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

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/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

View File

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

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: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');
}

View File

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

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