mirror of
https://git.selfprivacy.org/kherel/selfprivacy.org.app.git
synced 2024-11-16 05:33:17 +00:00
feat: Implement distinction for connection errors on initialing page
Now it's 'false' when api token is invalid and null response if couldn't connect at all, to show different kinds of errors to the user
This commit is contained in:
parent
58ce0f0f8b
commit
bd33b8d679
|
@ -273,6 +273,7 @@
|
|||
"place_where_data": "A place where your data and SelfPrivacy services will reside:",
|
||||
"how": "How to obtain API token",
|
||||
"provider_bad_key_error": "Provider API key is invalid",
|
||||
"could_not_connect": "Counldn't connect to the provider, please check your connection.",
|
||||
"choose_location_type": "Choose your server location and type:",
|
||||
"back_to_locations": "Go back to available locations!",
|
||||
"no_locations_found": "No available locations found. Make sure your account is accessible.",
|
||||
|
|
|
@ -272,6 +272,7 @@
|
|||
"place_where_data": "Здесь будут жить ваши данные и SelfPrivacy-сервисы:",
|
||||
"how": "Как получить API Token",
|
||||
"provider_bad_key_error": "API ключ провайдера неверен",
|
||||
"could_not_connect": "Не удалось соединиться с провайдером. Пожалуйста, проверьте подключение.",
|
||||
"choose_location_type": "Выберите локацию и тип вашего сервера:",
|
||||
"back_to_locations": "Назад к доступным локациям!",
|
||||
"no_locations_found": "Не найдено локаций. Убедитесь, что ваш аккаунт доступен.",
|
||||
|
|
13
lib/logic/api_maps/api_generic_result.dart
Normal file
13
lib/logic/api_maps/api_generic_result.dart
Normal file
|
@ -0,0 +1,13 @@
|
|||
class APIGenericResult<T> {
|
||||
APIGenericResult({
|
||||
required this.success,
|
||||
required this.data,
|
||||
this.message,
|
||||
});
|
||||
|
||||
/// Whether was a response successfully received,
|
||||
/// doesn't represent success of the request if `data<T>` is `bool`
|
||||
final bool success;
|
||||
final String? message;
|
||||
final T data;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:graphql/client.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/api_generic_result.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/api_map.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/disk_volumes.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/schema.graphql.dart';
|
||||
|
@ -22,27 +23,15 @@ import 'package:selfprivacy/logic/models/service.dart';
|
|||
import 'package:selfprivacy/logic/models/ssh_settings.dart';
|
||||
import 'package:selfprivacy/logic/models/system_settings.dart';
|
||||
|
||||
export 'package:selfprivacy/logic/api_maps/api_generic_result.dart';
|
||||
|
||||
part 'jobs_api.dart';
|
||||
part 'server_actions_api.dart';
|
||||
part 'services_api.dart';
|
||||
part 'users_api.dart';
|
||||
part 'volume_api.dart';
|
||||
|
||||
class GenericResult<T> {
|
||||
GenericResult({
|
||||
required this.success,
|
||||
required this.data,
|
||||
this.message,
|
||||
});
|
||||
|
||||
/// Whether was a response successfully received,
|
||||
/// doesn't represent success of the request if `data<T>` is `bool`
|
||||
final bool success;
|
||||
final String? message;
|
||||
final T data;
|
||||
}
|
||||
|
||||
class GenericMutationResult<T> extends GenericResult<T> {
|
||||
class GenericMutationResult<T> extends APIGenericResult<T> {
|
||||
GenericMutationResult({
|
||||
required super.success,
|
||||
required this.code,
|
||||
|
@ -206,7 +195,7 @@ class ServerApi extends ApiMap
|
|||
return settings;
|
||||
}
|
||||
|
||||
Future<GenericResult<RecoveryKeyStatus?>> getRecoveryTokenStatus() async {
|
||||
Future<APIGenericResult<RecoveryKeyStatus?>> getRecoveryTokenStatus() async {
|
||||
RecoveryKeyStatus? key;
|
||||
QueryResult<Query$RecoveryKey> response;
|
||||
String? error;
|
||||
|
@ -223,18 +212,18 @@ class ServerApi extends ApiMap
|
|||
print(e);
|
||||
}
|
||||
|
||||
return GenericResult<RecoveryKeyStatus?>(
|
||||
return APIGenericResult<RecoveryKeyStatus?>(
|
||||
success: error == null,
|
||||
data: key,
|
||||
message: error,
|
||||
);
|
||||
}
|
||||
|
||||
Future<GenericResult<String>> generateRecoveryToken(
|
||||
Future<APIGenericResult<String>> generateRecoveryToken(
|
||||
final DateTime? expirationDate,
|
||||
final int? numberOfUses,
|
||||
) async {
|
||||
GenericResult<String> key;
|
||||
APIGenericResult<String> key;
|
||||
QueryResult<Mutation$GetNewRecoveryApiKey> response;
|
||||
|
||||
try {
|
||||
|
@ -255,19 +244,19 @@ class ServerApi extends ApiMap
|
|||
);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
key = GenericResult<String>(
|
||||
key = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: response.exception.toString(),
|
||||
);
|
||||
}
|
||||
key = GenericResult<String>(
|
||||
key = APIGenericResult<String>(
|
||||
success: true,
|
||||
data: response.parsedData!.getNewRecoveryApiKey.key!,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
key = GenericResult<String>(
|
||||
key = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: e.toString(),
|
||||
|
@ -300,8 +289,8 @@ class ServerApi extends ApiMap
|
|||
return records;
|
||||
}
|
||||
|
||||
Future<GenericResult<List<ApiToken>>> getApiTokens() async {
|
||||
GenericResult<List<ApiToken>> tokens;
|
||||
Future<APIGenericResult<List<ApiToken>>> getApiTokens() async {
|
||||
APIGenericResult<List<ApiToken>> tokens;
|
||||
QueryResult<Query$GetApiTokens> response;
|
||||
|
||||
try {
|
||||
|
@ -310,7 +299,7 @@ class ServerApi extends ApiMap
|
|||
if (response.hasException) {
|
||||
final message = response.exception.toString();
|
||||
print(message);
|
||||
tokens = GenericResult<List<ApiToken>>(
|
||||
tokens = APIGenericResult<List<ApiToken>>(
|
||||
success: false,
|
||||
data: [],
|
||||
message: message,
|
||||
|
@ -324,13 +313,13 @@ class ServerApi extends ApiMap
|
|||
ApiToken.fromGraphQL(device),
|
||||
)
|
||||
.toList();
|
||||
tokens = GenericResult<List<ApiToken>>(
|
||||
tokens = APIGenericResult<List<ApiToken>>(
|
||||
success: true,
|
||||
data: parsed,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
tokens = GenericResult<List<ApiToken>>(
|
||||
tokens = APIGenericResult<List<ApiToken>>(
|
||||
success: false,
|
||||
data: [],
|
||||
message: e.toString(),
|
||||
|
@ -340,8 +329,8 @@ class ServerApi extends ApiMap
|
|||
return tokens;
|
||||
}
|
||||
|
||||
Future<GenericResult<void>> deleteApiToken(final String name) async {
|
||||
GenericResult<void> returnable;
|
||||
Future<APIGenericResult<void>> deleteApiToken(final String name) async {
|
||||
APIGenericResult<void> returnable;
|
||||
QueryResult<Mutation$DeleteDeviceApiToken> response;
|
||||
|
||||
try {
|
||||
|
@ -358,19 +347,19 @@ class ServerApi extends ApiMap
|
|||
);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
returnable = GenericResult<void>(
|
||||
returnable = APIGenericResult<void>(
|
||||
success: false,
|
||||
data: null,
|
||||
message: response.exception.toString(),
|
||||
);
|
||||
}
|
||||
returnable = GenericResult<void>(
|
||||
returnable = APIGenericResult<void>(
|
||||
success: true,
|
||||
data: null,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
returnable = GenericResult<void>(
|
||||
returnable = APIGenericResult<void>(
|
||||
success: false,
|
||||
data: null,
|
||||
message: e.toString(),
|
||||
|
@ -380,8 +369,8 @@ class ServerApi extends ApiMap
|
|||
return returnable;
|
||||
}
|
||||
|
||||
Future<GenericResult<String>> createDeviceToken() async {
|
||||
GenericResult<String> token;
|
||||
Future<APIGenericResult<String>> createDeviceToken() async {
|
||||
APIGenericResult<String> token;
|
||||
QueryResult<Mutation$GetNewDeviceApiKey> response;
|
||||
|
||||
try {
|
||||
|
@ -393,19 +382,19 @@ class ServerApi extends ApiMap
|
|||
);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: response.exception.toString(),
|
||||
);
|
||||
}
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: true,
|
||||
data: response.parsedData!.getNewDeviceApiKey.key!,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: e.toString(),
|
||||
|
@ -417,10 +406,10 @@ class ServerApi extends ApiMap
|
|||
|
||||
Future<bool> isHttpServerWorking() async => (await getApiVersion()) != null;
|
||||
|
||||
Future<GenericResult<String>> authorizeDevice(
|
||||
Future<APIGenericResult<String>> authorizeDevice(
|
||||
final DeviceToken deviceToken,
|
||||
) async {
|
||||
GenericResult<String> token;
|
||||
APIGenericResult<String> token;
|
||||
QueryResult<Mutation$AuthorizeWithNewDeviceApiKey> response;
|
||||
|
||||
try {
|
||||
|
@ -442,19 +431,19 @@ class ServerApi extends ApiMap
|
|||
);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: response.exception.toString(),
|
||||
);
|
||||
}
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: true,
|
||||
data: response.parsedData!.authorizeWithNewDeviceApiKey.token!,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: e.toString(),
|
||||
|
@ -464,10 +453,10 @@ class ServerApi extends ApiMap
|
|||
return token;
|
||||
}
|
||||
|
||||
Future<GenericResult<String>> useRecoveryToken(
|
||||
Future<APIGenericResult<String>> useRecoveryToken(
|
||||
final DeviceToken deviceToken,
|
||||
) async {
|
||||
GenericResult<String> token;
|
||||
APIGenericResult<String> token;
|
||||
QueryResult<Mutation$UseRecoveryApiKey> response;
|
||||
|
||||
try {
|
||||
|
@ -489,19 +478,19 @@ class ServerApi extends ApiMap
|
|||
);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: response.exception.toString(),
|
||||
);
|
||||
}
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: true,
|
||||
data: response.parsedData!.useRecoveryApiKey.token!,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
token = GenericResult<String>(
|
||||
token = APIGenericResult<String>(
|
||||
success: false,
|
||||
data: '',
|
||||
message: e.toString(),
|
||||
|
|
|
@ -59,35 +59,50 @@ class DigitalOceanApi extends ServerProviderApi with VolumeProviderApi {
|
|||
String get displayProviderName => 'Digital Ocean';
|
||||
|
||||
@override
|
||||
Future<bool> isApiTokenValid(final String token) async {
|
||||
Future<APIGenericResult<bool>> isApiTokenValid(final String token) async {
|
||||
bool isValid = false;
|
||||
Response? response;
|
||||
String message = '';
|
||||
final Dio client = await getClient();
|
||||
try {
|
||||
response = await client.get(
|
||||
'/account',
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (final status) =>
|
||||
status != null && (status >= 200 || status == 401),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
isValid = false;
|
||||
message = e.toString();
|
||||
} finally {
|
||||
close(client);
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
isValid = true;
|
||||
} else if (response.statusCode == HttpStatus.unauthorized) {
|
||||
isValid = false;
|
||||
} else {
|
||||
throw Exception('code: ${response.statusCode}');
|
||||
}
|
||||
if (response == null) {
|
||||
return APIGenericResult(
|
||||
data: isValid,
|
||||
success: false,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
isValid = true;
|
||||
} else if (response.statusCode == HttpStatus.unauthorized) {
|
||||
isValid = false;
|
||||
} else {
|
||||
throw Exception('code: ${response.statusCode}');
|
||||
}
|
||||
|
||||
return APIGenericResult(
|
||||
data: isValid,
|
||||
success: true,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hardcoded on their documentation and there is no pricing API at all
|
||||
|
|
|
@ -60,35 +60,50 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi {
|
|||
String get displayProviderName => 'Hetzner';
|
||||
|
||||
@override
|
||||
Future<bool> isApiTokenValid(final String token) async {
|
||||
Future<APIGenericResult<bool>> isApiTokenValid(final String token) async {
|
||||
bool isValid = false;
|
||||
Response? response;
|
||||
String message = '';
|
||||
final Dio client = await getClient();
|
||||
try {
|
||||
response = await client.get(
|
||||
'/servers',
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (final status) =>
|
||||
status != null && (status >= 200 || status == 401),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
isValid = false;
|
||||
message = e.toString();
|
||||
} finally {
|
||||
close(client);
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
isValid = true;
|
||||
} else if (response.statusCode == HttpStatus.unauthorized) {
|
||||
isValid = false;
|
||||
} else {
|
||||
throw Exception('code: ${response.statusCode}');
|
||||
}
|
||||
if (response == null) {
|
||||
return APIGenericResult(
|
||||
data: isValid,
|
||||
success: false,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
isValid = true;
|
||||
} else if (response.statusCode == HttpStatus.unauthorized) {
|
||||
isValid = false;
|
||||
} else {
|
||||
throw Exception('code: ${response.statusCode}');
|
||||
}
|
||||
|
||||
return APIGenericResult(
|
||||
data: isValid,
|
||||
success: true,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:selfprivacy/logic/api_maps/api_generic_result.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/rest_maps/api_map.dart';
|
||||
import 'package:selfprivacy/logic/models/hive/server_details.dart';
|
||||
import 'package:selfprivacy/logic/models/hive/server_domain.dart';
|
||||
|
@ -8,6 +9,8 @@ import 'package:selfprivacy/logic/models/server_metadata.dart';
|
|||
import 'package:selfprivacy/logic/models/server_provider_location.dart';
|
||||
import 'package:selfprivacy/logic/models/server_type.dart';
|
||||
|
||||
export 'package:selfprivacy/logic/api_maps/api_generic_result.dart';
|
||||
|
||||
class ProviderApiTokenValidation {
|
||||
ProviderApiTokenValidation({
|
||||
required this.length,
|
||||
|
@ -39,7 +42,7 @@ abstract class ServerProviderApi extends ApiMap {
|
|||
required final ServerDomain domain,
|
||||
});
|
||||
|
||||
Future<bool> isApiTokenValid(final String token);
|
||||
Future<APIGenericResult<bool>> isApiTokenValid(final String token);
|
||||
ProviderApiTokenValidation getApiTokenValidation();
|
||||
Future<List<ServerMetadataEntity>> getMetadata(final int serverId);
|
||||
Future<ServerMetrics?> getMetrics(
|
||||
|
|
|
@ -35,7 +35,7 @@ class ApiDevicesCubit
|
|||
}
|
||||
|
||||
Future<List<ApiToken>?> _getApiTokens() async {
|
||||
final GenericResult<List<ApiToken>> response = await api.getApiTokens();
|
||||
final APIGenericResult<List<ApiToken>> response = await api.getApiTokens();
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
} else {
|
||||
|
@ -44,7 +44,8 @@ class ApiDevicesCubit
|
|||
}
|
||||
|
||||
Future<void> deleteDevice(final ApiToken device) async {
|
||||
final GenericResult<void> response = await api.deleteApiToken(device.name);
|
||||
final APIGenericResult<void> response =
|
||||
await api.deleteApiToken(device.name);
|
||||
if (response.success) {
|
||||
emit(
|
||||
ApiDevicesState(
|
||||
|
@ -59,7 +60,7 @@ class ApiDevicesCubit
|
|||
}
|
||||
|
||||
Future<String?> getNewDeviceKey() async {
|
||||
final GenericResult<String> response = await api.createDeviceToken();
|
||||
final APIGenericResult<String> response = await api.createDeviceToken();
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
} else {
|
||||
|
|
|
@ -29,21 +29,24 @@ class ProviderFormCubit extends FormCubit {
|
|||
|
||||
@override
|
||||
FutureOr<bool> asyncValidation() async {
|
||||
late bool isKeyValid;
|
||||
bool? isKeyValid;
|
||||
|
||||
try {
|
||||
isKeyValid = await serverInstallationCubit
|
||||
.isServerProviderApiTokenValid(apiKey.state.value);
|
||||
} catch (e) {
|
||||
addError(e);
|
||||
isKeyValid = false;
|
||||
}
|
||||
|
||||
if (isKeyValid == null) {
|
||||
apiKey.setError('');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isKeyValid) {
|
||||
apiKey.setError('initializing.provider_bad_key_error'.tr());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isKeyValid;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ class RecoveryKeyCubit
|
|||
}
|
||||
|
||||
Future<RecoveryKeyStatus?> _getRecoveryKeyStatus() async {
|
||||
final GenericResult<RecoveryKeyStatus?> response =
|
||||
final APIGenericResult<RecoveryKeyStatus?> response =
|
||||
await api.getRecoveryTokenStatus();
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
|
@ -57,7 +57,7 @@ class RecoveryKeyCubit
|
|||
final DateTime? expirationDate,
|
||||
final int? numberOfUses,
|
||||
}) async {
|
||||
final GenericResult<String> response =
|
||||
final APIGenericResult<String> response =
|
||||
await api.generateRecoveryToken(expirationDate, numberOfUses);
|
||||
if (response.success) {
|
||||
refresh();
|
||||
|
|
|
@ -76,16 +76,27 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
|
|||
.getDnsProvider()
|
||||
.getApiTokenValidation();
|
||||
|
||||
Future<bool> isServerProviderApiTokenValid(
|
||||
Future<bool?> isServerProviderApiTokenValid(
|
||||
final String providerToken,
|
||||
) async =>
|
||||
ApiController.currentServerProviderApiFactory!
|
||||
.getServerProvider(
|
||||
settings: const ServerProviderApiSettings(
|
||||
isWithToken: false,
|
||||
),
|
||||
)
|
||||
.isApiTokenValid(providerToken);
|
||||
) async {
|
||||
final APIGenericResult<bool> apiResponse =
|
||||
await ApiController.currentServerProviderApiFactory!
|
||||
.getServerProvider(
|
||||
settings: const ServerProviderApiSettings(
|
||||
isWithToken: false,
|
||||
),
|
||||
)
|
||||
.isApiTokenValid(providerToken);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
getIt<NavigationService>().showSnackBar(
|
||||
'initializing.could_not_connect'.tr(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiResponse.data;
|
||||
}
|
||||
|
||||
Future<bool> isDnsProviderApiTokenValid(
|
||||
final String providerToken,
|
||||
|
|
|
@ -479,7 +479,7 @@ class ServerInstallationRepository {
|
|||
overrideDomain: serverDomain.domainName,
|
||||
);
|
||||
final String serverIp = await getServerIpFromDomain(serverDomain);
|
||||
final GenericResult<String> result = await serverApi.authorizeDevice(
|
||||
final APIGenericResult<String> result = await serverApi.authorizeDevice(
|
||||
DeviceToken(device: await getDeviceName(), token: newDeviceKey),
|
||||
);
|
||||
|
||||
|
@ -516,7 +516,7 @@ class ServerInstallationRepository {
|
|||
overrideDomain: serverDomain.domainName,
|
||||
);
|
||||
final String serverIp = await getServerIpFromDomain(serverDomain);
|
||||
final GenericResult<String> result = await serverApi.useRecoveryToken(
|
||||
final APIGenericResult<String> result = await serverApi.useRecoveryToken(
|
||||
DeviceToken(device: await getDeviceName(), token: recoveryKey),
|
||||
);
|
||||
|
||||
|
@ -577,9 +577,9 @@ class ServerInstallationRepository {
|
|||
);
|
||||
}
|
||||
}
|
||||
final GenericResult<String> deviceAuthKey =
|
||||
final APIGenericResult<String> deviceAuthKey =
|
||||
await serverApi.createDeviceToken();
|
||||
final GenericResult<String> result = await serverApi.authorizeDevice(
|
||||
final APIGenericResult<String> result = await serverApi.authorizeDevice(
|
||||
DeviceToken(device: await getDeviceName(), token: deviceAuthKey.data),
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue