diff --git a/assets/translations/en.json b/assets/translations/en.json index a0ce11e6..bf8e4e2c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -264,9 +264,10 @@ }, "initializing": { "connect_to_server": "Connect a server", + "select_provider": "Select your provider", "place_where_data": "A place where your data and SelfPrivacy services will reside:", "how": "How to obtain API token", - "hetzner_bad_key_error": "Hetzner API key is invalid", + "provider_bad_key_error": "Provider API key is invalid", "cloudflare_bad_key_error": "Cloudflare API key is invalid", "backblaze_bad_key_error": "Backblaze storage information is invalid", "connect_cloudflare": "Connect CloudFlare", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 04b832da..604edf35 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -266,7 +266,7 @@ "connect_to_server": "Подключите сервер", "place_where_data": "Здесь будут жить ваши данные и SelfPrivacy-сервисы:", "how": "Как получить API Token", - "hetzner_bad_key_error": "Hetzner API ключ неверен", + "provider_bad_key_error": "API ключ провайдера неверен", "cloudflare_bad_key_error": "Cloudflare API ключ неверен", "backblaze_bad_key_error": "Информация о Backblaze хранилище неверна", "connect_cloudflare": "Подключите CloudFlare", diff --git a/lib/logic/api_maps/rest_maps/api_factory_creator.dart b/lib/logic/api_maps/rest_maps/api_factory_creator.dart index 18b4ea33..a144c647 100644 --- a/lib/logic/api_maps/rest_maps/api_factory_creator.dart +++ b/lib/logic/api_maps/rest_maps/api_factory_creator.dart @@ -1,5 +1,6 @@ import 'package:selfprivacy/logic/api_maps/rest_maps/dns_providers/cloudflare/cloudflare_factory.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/dns_providers/dns_provider_factory.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_factory.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_factory.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider_factory.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; @@ -17,6 +18,8 @@ class ApiFactoryCreator { switch (provider) { case ServerProvider.hetzner: return HetznerApiFactory(); + case ServerProvider.digitalOcean: + return DigitalOceanApiFactory(); case ServerProvider.unknown: throw UnknownApiProviderException('Unknown server provider'); } @@ -41,6 +44,8 @@ class VolumeApiFactoryCreator { switch (provider) { case ServerProvider.hetzner: return HetznerApiFactory(); + case ServerProvider.digitalOcean: + return DigitalOceanApiFactory(); case ServerProvider.unknown: throw UnknownApiProviderException('Unknown volume provider'); } diff --git a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean.dart b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean.dart new file mode 100644 index 00000000..159b68d8 --- /dev/null +++ b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean.dart @@ -0,0 +1,559 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/volume_provider.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider.dart'; +import 'package:selfprivacy/logic/models/hive/server_domain.dart'; +import 'package:selfprivacy/logic/models/json/hetzner_server_info.dart'; +import 'package:selfprivacy/logic/models/hive/server_details.dart'; +import 'package:selfprivacy/logic/models/hive/user.dart'; +import 'package:selfprivacy/logic/models/server_basic_info.dart'; +import 'package:selfprivacy/utils/password_generator.dart'; + +class DigitalOceanApi extends ServerProviderApi with VolumeProviderApi { + DigitalOceanApi( + {final this.hasLogger = false, final this.isWithToken = true}); + @override + bool hasLogger; + @override + bool isWithToken; + + @override + BaseOptions get options { + final BaseOptions options = BaseOptions(baseUrl: rootAddress); + if (isWithToken) { + final String? token = getIt().serverProviderKey; + assert(token != null); + options.headers = {'Authorization': 'Bearer $token'}; + } + + if (validateStatus != null) { + options.validateStatus = validateStatus!; + } + + return options; + } + + @override + String rootAddress = 'https://api.digitalocean.com/v2'; + + @override + Future isApiTokenValid(final String token) async { + bool isValid = false; + Response? response; + final Dio client = await getClient(); + try { + response = await client.get( + '/servers', + options: Options( + headers: {'Authorization': 'Bearer $token'}, + ), + ); + } catch (e) { + print(e); + isValid = false; + } 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}'); + } + } + + return isValid; + } + + @override + RegExp getApiTokenValidation() => + RegExp(r'\s+|[-!$%^&*()@+|~=`{}\[\]:<>?,.\/]'); + + @override + Future getPricePerGb() async { + double? price; + + final Response dbGetResponse; + final Dio client = await getClient(); + try { + dbGetResponse = await client.get('/pricing'); + + final volume = dbGetResponse.data['pricing']['volume']; + final volumePrice = volume['price_per_gb_month']['gross']; + price = double.parse(volumePrice); + } catch (e) { + print(e); + } finally { + client.close(); + } + + return price; + } + + @override + Future createVolume() async { + ServerVolume? volume; + + final Response dbCreateResponse; + final Dio client = await getClient(); + try { + dbCreateResponse = await client.post( + '/volumes', + data: { + 'size': 10, + 'name': StringGenerators.dbStorageName(), + 'labels': {'labelkey': 'value'}, + 'location': 'fsn1', + 'automount': false, + 'format': 'ext4' + }, + ); + final dbId = dbCreateResponse.data['volume']['id']; + final dbSize = dbCreateResponse.data['volume']['size']; + final dbServer = dbCreateResponse.data['volume']['server']; + final dbName = dbCreateResponse.data['volume']['name']; + final dbDevice = dbCreateResponse.data['volume']['linux_device']; + volume = ServerVolume( + id: dbId, + name: dbName, + sizeByte: dbSize, + serverId: dbServer, + linuxDevice: dbDevice, + ); + } catch (e) { + print(e); + } finally { + client.close(); + } + + return volume; + } + + @override + Future> getVolumes({final String? status}) async { + final List volumes = []; + + final Response dbGetResponse; + final Dio client = await getClient(); + try { + dbGetResponse = await client.get( + '/volumes', + queryParameters: { + 'status': status, + }, + ); + final List rawVolumes = dbGetResponse.data['volumes']; + for (final rawVolume in rawVolumes) { + final int dbId = rawVolume['id']; + final int dbSize = rawVolume['size'] * 1024 * 1024 * 1024; + final dbServer = rawVolume['server']; + final String dbName = rawVolume['name']; + final dbDevice = rawVolume['linux_device']; + final volume = ServerVolume( + id: dbId, + name: dbName, + sizeByte: dbSize, + serverId: dbServer, + linuxDevice: dbDevice, + ); + volumes.add(volume); + } + } catch (e) { + print(e); + } finally { + client.close(); + } + + return volumes; + } + + @override + Future getVolume(final int id) async { + ServerVolume? volume; + + final Response dbGetResponse; + final Dio client = await getClient(); + try { + dbGetResponse = await client.get('/volumes/$id'); + final int dbId = dbGetResponse.data['volume']['id']; + final int dbSize = dbGetResponse.data['volume']['size']; + final int dbServer = dbGetResponse.data['volume']['server']; + final String dbName = dbGetResponse.data['volume']['name']; + final dbDevice = dbGetResponse.data['volume']['linux_device']; + volume = ServerVolume( + id: dbId, + name: dbName, + sizeByte: dbSize, + serverId: dbServer, + linuxDevice: dbDevice, + ); + } catch (e) { + print(e); + } finally { + client.close(); + } + + return volume; + } + + @override + Future deleteVolume(final int id) async { + final Dio client = await getClient(); + try { + await client.delete('/volumes/$id'); + } catch (e) { + print(e); + } finally { + client.close(); + } + } + + @override + Future attachVolume(final int volumeId, final int serverId) async { + bool success = false; + + final Response dbPostResponse; + final Dio client = await getClient(); + try { + dbPostResponse = await client.post( + '/volumes/$volumeId/actions/attach', + data: { + 'automount': true, + 'server': serverId, + }, + ); + success = dbPostResponse.data['action']['status'].toString() != 'error'; + } catch (e) { + print(e); + } finally { + client.close(); + } + + return success; + } + + @override + Future detachVolume(final int volumeId) async { + bool success = false; + + final Response dbPostResponse; + final Dio client = await getClient(); + try { + dbPostResponse = await client.post('/volumes/$volumeId/actions/detach'); + success = dbPostResponse.data['action']['status'].toString() != 'error'; + } catch (e) { + print(e); + } finally { + client.close(); + } + + return success; + } + + @override + Future resizeVolume(final int volumeId, final int sizeGb) async { + bool success = false; + + final Response dbPostResponse; + final Dio client = await getClient(); + try { + dbPostResponse = await client.post( + '/volumes/$volumeId/actions/resize', + data: { + 'size': sizeGb, + }, + ); + success = dbPostResponse.data['action']['status'].toString() != 'error'; + } catch (e) { + print(e); + } finally { + client.close(); + } + + return success; + } + + @override + Future createServer({ + required final String dnsApiToken, + required final User rootUser, + required final String domainName, + }) async { + ServerHostingDetails? details; + + final ServerVolume? newVolume = await createVolume(); + if (newVolume == null) { + return details; + } + + details = await createServerWithVolume( + dnsApiToken: dnsApiToken, + rootUser: rootUser, + domainName: domainName, + dataBase: newVolume, + ); + + return details; + } + + Future createServerWithVolume({ + required final String dnsApiToken, + required final User rootUser, + required final String domainName, + required final ServerVolume dataBase, + }) async { + final Dio client = await getClient(); + + final String dbPassword = StringGenerators.dbPassword(); + final int dbId = dataBase.id; + + final String apiToken = StringGenerators.apiToken(); + + final String hostname = getHostnameFromDomain(domainName); + + final String base64Password = + base64.encode(utf8.encode(rootUser.password ?? 'PASS')); + + print('hostname: $hostname'); + + /// add ssh key when you need it: e.g. "ssh_keys":["kherel"] + /// check the branch name, it could be "development" or "master". + /// + final String userdataString = + "#cloud-config\nruncmd:\n- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/master/nixos-infect | PROVIDER=hetzner NIX_CHANNEL=nixos-21.05 DOMAIN='$domainName' LUSER='${rootUser.login}' ENCODED_PASSWORD='$base64Password' CF_TOKEN=$dnsApiToken DB_PASSWORD=$dbPassword API_TOKEN=$apiToken HOSTNAME=$hostname bash 2>&1 | tee /tmp/infect.log"; + print(userdataString); + + final Map data = { + 'name': hostname, + 'server_type': 'cx11', + 'start_after_create': false, + 'image': 'ubuntu-20.04', + 'volumes': [dbId], + 'networks': [], + 'user_data': userdataString, + 'labels': {}, + 'automount': true, + 'location': 'fsn1' + }; + print('Decoded data: $data'); + + ServerHostingDetails? serverDetails; + DioError? hetznerError; + bool success = false; + + try { + final Response serverCreateResponse = await client.post( + '/servers', + data: data, + ); + print(serverCreateResponse.data); + serverDetails = ServerHostingDetails( + id: serverCreateResponse.data['server']['id'], + ip4: serverCreateResponse.data['server']['public_net']['ipv4']['ip'], + createTime: DateTime.now(), + volume: dataBase, + apiToken: apiToken, + provider: ServerProvider.hetzner, + ); + success = true; + } on DioError catch (e) { + print(e); + hetznerError = e; + } catch (e) { + print(e); + } finally { + client.close(); + } + + if (!success) { + await Future.delayed(const Duration(seconds: 10)); + await deleteVolume(dbId); + } + + if (hetznerError != null) { + throw hetznerError; + } + + return serverDetails; + } + + static String getHostnameFromDomain(final String domain) { + // Replace all non-alphanumeric characters with an underscore + String hostname = + domain.split('.')[0].replaceAll(RegExp(r'[^a-zA-Z0-9]'), '-'); + if (hostname.endsWith('-')) { + hostname = hostname.substring(0, hostname.length - 1); + } + if (hostname.startsWith('-')) { + hostname = hostname.substring(1); + } + if (hostname.isEmpty) { + hostname = 'selfprivacy-server'; + } + + return hostname; + } + + @override + Future deleteServer({ + required final String domainName, + }) async { + final Dio client = await getClient(); + + final String hostname = getHostnameFromDomain(domainName); + + final Response serversReponse = await client.get('/servers'); + final List servers = serversReponse.data['servers']; + final Map server = servers.firstWhere((final el) => el['name'] == hostname); + final List volumes = server['volumes']; + final List laterFutures = []; + + for (final volumeId in volumes) { + await client.post('/volumes/$volumeId/actions/detach'); + } + await Future.delayed(const Duration(seconds: 10)); + + for (final volumeId in volumes) { + laterFutures.add(client.delete('/volumes/$volumeId')); + } + laterFutures.add(client.delete('/servers/${server['id']}')); + + await Future.wait(laterFutures); + close(client); + } + + @override + Future restart() async { + final ServerHostingDetails server = getIt().serverDetails!; + + final Dio client = await getClient(); + try { + await client.post('/servers/${server.id}/actions/reset'); + } catch (e) { + print(e); + } finally { + close(client); + } + + return server.copyWith(startTime: DateTime.now()); + } + + @override + Future powerOn() async { + final ServerHostingDetails server = getIt().serverDetails!; + + final Dio client = await getClient(); + try { + await client.post('/servers/${server.id}/actions/poweron'); + } catch (e) { + print(e); + } finally { + close(client); + } + + return server.copyWith(startTime: DateTime.now()); + } + + Future> getMetrics( + final DateTime start, + final DateTime end, + final String type, + ) async { + final ServerHostingDetails? hetznerServer = + getIt().serverDetails; + + Map metrics = {}; + final Dio client = await getClient(); + try { + final Map queryParameters = { + 'start': start.toUtc().toIso8601String(), + 'end': end.toUtc().toIso8601String(), + 'type': type + }; + final Response res = await client.get( + '/servers/${hetznerServer!.id}/metrics', + queryParameters: queryParameters, + ); + metrics = res.data; + } catch (e) { + print(e); + } finally { + close(client); + } + + return metrics; + } + + Future getInfo() async { + final ServerHostingDetails? hetznerServer = + getIt().serverDetails; + final Dio client = await getClient(); + final Response response = await client.get('/servers/${hetznerServer!.id}'); + close(client); + + return HetznerServerInfo.fromJson(response.data!['server']); + } + + @override + Future> getServers() async { + List servers = []; + + final Dio client = await getClient(); + try { + final Response response = await client.get('/servers'); + servers = response.data!['servers'] + .map( + (final e) => HetznerServerInfo.fromJson(e), + ) + .toList() + .where( + (final server) => server.publicNet.ipv4 != null, + ) + .map( + (final server) => ServerBasicInfo( + id: server.id, + name: server.name, + ip: server.publicNet.ipv4.ip, + reverseDns: server.publicNet.ipv4.reverseDns, + created: server.created, + volumeId: server.volumes.isNotEmpty ? server.volumes[0] : 0, + ), + ) + .toList(); + } catch (e) { + print(e); + } finally { + close(client); + } + + print(servers); + return servers; + } + + @override + Future createReverseDns({ + required final ServerHostingDetails serverDetails, + required final ServerDomain domain, + }) async { + final Dio client = await getClient(); + try { + await client.post( + '/servers/${serverDetails.id}/actions/change_dns_ptr', + data: { + 'ip': serverDetails.ip4, + 'dns_ptr': domain.domainName, + }, + ); + } catch (e) { + print(e); + } finally { + close(client); + } + } +} diff --git a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_factory.dart b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_factory.dart new file mode 100644 index 00000000..565f7c84 --- /dev/null +++ b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_factory.dart @@ -0,0 +1,26 @@ +import 'package:selfprivacy/logic/api_maps/rest_maps/provider_api_settings.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider_factory.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/volume_provider.dart'; + +class DigitalOceanApiFactory extends ServerProviderApiFactory + with VolumeProviderApiFactory { + @override + ServerProviderApi getServerProvider({ + final ProviderApiSettings settings = const ProviderApiSettings(), + }) => + DigitalOceanApi( + hasLogger: settings.hasLogger, + isWithToken: settings.isWithToken, + ); + + @override + VolumeProviderApi getVolumeProvider({ + final ProviderApiSettings settings = const ProviderApiSettings(), + }) => + DigitalOceanApi( + hasLogger: settings.hasLogger, + isWithToken: settings.isWithToken, + ); +} diff --git a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart index 8340676d..f26646ce 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart @@ -23,7 +23,7 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { BaseOptions get options { final BaseOptions options = BaseOptions(baseUrl: rootAddress); if (isWithToken) { - final String? token = getIt().hetznerKey; + final String? token = getIt().serverProviderKey; assert(token != null); options.headers = {'Authorization': 'Bearer $token'}; } diff --git a/lib/logic/cubit/forms/setup/initializing/provider_form_cubit.dart b/lib/logic/cubit/forms/setup/initializing/provider_form_cubit.dart index 44f40b57..727daea8 100644 --- a/lib/logic/cubit/forms/setup/initializing/provider_form_cubit.dart +++ b/lib/logic/cubit/forms/setup/initializing/provider_form_cubit.dart @@ -26,7 +26,7 @@ class ProviderFormCubit extends FormCubit { @override FutureOr onSubmit() async { - serverInstallationCubit.setHetznerKey(apiKey.state.value); + serverInstallationCubit.setServerProviderKey(apiKey.state.value); } final ServerInstallationCubit serverInstallationCubit; @@ -45,7 +45,7 @@ class ProviderFormCubit extends FormCubit { } if (!isKeyValid) { - apiKey.setError('initializing.hetzner_bad_key_error'.tr()); + apiKey.setError('initializing.provider_bad_key_error'.tr()); return false; } diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index 9cb68359..b32fe79a 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/api_maps/rest_maps/api_factory_creator.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/dns_providers/dns_provider_factory.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/provider_api_settings.dart'; import 'package:selfprivacy/logic/models/hive/backblaze_credential.dart'; @@ -51,6 +52,13 @@ class ServerInstallationCubit extends Cubit { } } + void setServerProviderType(final ServerProvider providerType) { + repository.serverProviderApiFactory = + ApiFactoryCreator.createServerProviderApiFactory( + providerType, + ); + } + RegExp getServerProviderApiTokenValidation() => repository.serverProviderApiFactory! .getServerProvider() @@ -78,13 +86,13 @@ class ServerInstallationCubit extends Cubit { ) .isApiTokenValid(providerToken); - void setHetznerKey(final String hetznerKey) async { - await repository.saveHetznerKey(hetznerKey); + void setServerProviderKey(final String serverProviderKey) async { + await repository.saveServerProviderKey(serverProviderKey); if (state is ServerInstallationRecovery) { emit( (state as ServerInstallationRecovery).copyWith( - providerApiToken: hetznerKey, + providerApiToken: serverProviderKey, currentStep: RecoveryStep.serverSelection, ), ); @@ -93,7 +101,7 @@ class ServerInstallationCubit extends Cubit { emit( (state as ServerInstallationNotFinished).copyWith( - providerApiToken: hetznerKey, + providerApiToken: serverProviderKey, ), ); } @@ -427,7 +435,7 @@ class ServerInstallationCubit extends Cubit { emit( dataState.copyWith( serverDetails: serverDetails, - currentStep: RecoveryStep.hetznerToken, + currentStep: RecoveryStep.serverProviderToken, ), ); } on ServerAuthorizationException { diff --git a/lib/logic/cubit/server_installation/server_installation_repository.dart b/lib/logic/cubit/server_installation/server_installation_repository.dart index a83e9deb..39593b1f 100644 --- a/lib/logic/cubit/server_installation/server_installation_repository.dart +++ b/lib/logic/cubit/server_installation/server_installation_repository.dart @@ -39,17 +39,14 @@ class ServerAuthorizationException implements Exception { class ServerInstallationRepository { Box box = Hive.box(BNames.serverInstallationBox); Box usersBox = Hive.box(BNames.usersBox); - ServerProviderApiFactory? serverProviderApiFactory = - ApiFactoryCreator.createServerProviderApiFactory( - ServerProvider.hetzner, // TODO: HARDCODE FOR NOW!!! - ); // TODO: Remove when provider selection is implemented. + ServerProviderApiFactory? serverProviderApiFactory; DnsProviderApiFactory? dnsProviderApiFactory = ApiFactoryCreator.createDnsProviderApiFactory( DnsProvider.cloudflare, // TODO: HARDCODE FOR NOW!!! ); Future load() async { - final String? providerApiToken = getIt().hetznerKey; + final String? providerApiToken = getIt().serverProviderKey; final String? cloudflareToken = getIt().cloudFlareKey; final ServerDomain? serverDomain = getIt().serverDomain; final BackblazeCredential? backblazeCredential = @@ -124,13 +121,13 @@ class ServerInstallationRepository { } RecoveryStep _getCurrentRecoveryStep( - final String? hetznerToken, + final String? serverProviderToken, final String? cloudflareToken, final ServerDomain serverDomain, final ServerHostingDetails? serverDetails, ) { if (serverDetails != null) { - if (hetznerToken != null) { + if (serverProviderToken != null) { if (serverDetails.provider != ServerProvider.unknown) { if (serverDomain.provider != DnsProvider.unknown) { return RecoveryStep.backblazeToken; @@ -139,7 +136,7 @@ class ServerInstallationRepository { } return RecoveryStep.serverSelection; } - return RecoveryStep.hetznerToken; + return RecoveryStep.serverProviderToken; } return RecoveryStep.selecting; } @@ -150,7 +147,7 @@ class ServerInstallationRepository { } Future startServer( - final ServerHostingDetails hetznerServer, + final ServerHostingDetails server, ) async { ServerHostingDetails serverDetails; @@ -670,12 +667,11 @@ class ServerInstallationRepository { getIt().init(); } - Future saveHetznerKey(final String key) async { - print('saved'); - await getIt().storeHetznerKey(key); + Future saveServerProviderKey(final String key) async { + await getIt().storeServerProviderKey(key); } - Future deleteHetznerKey() async { + Future deleteServerProviderKey() async { await box.delete(BNames.hetznerKey); getIt().init(); } diff --git a/lib/logic/cubit/server_installation/server_installation_state.dart b/lib/logic/cubit/server_installation/server_installation_state.dart index bb04c07d..82eda971 100644 --- a/lib/logic/cubit/server_installation/server_installation_state.dart +++ b/lib/logic/cubit/server_installation/server_installation_state.dart @@ -238,7 +238,7 @@ enum RecoveryStep { recoveryKey, newDeviceKey, oldToken, - hetznerToken, + serverProviderToken, serverSelection, cloudflareToken, backblazeToken, diff --git a/lib/logic/get_it/api_config.dart b/lib/logic/get_it/api_config.dart index ec2feb55..6a81141f 100644 --- a/lib/logic/get_it/api_config.dart +++ b/lib/logic/get_it/api_config.dart @@ -9,22 +9,22 @@ class ApiConfigModel { final Box _box = Hive.box(BNames.serverInstallationBox); ServerHostingDetails? get serverDetails => _serverDetails; - String? get hetznerKey => _hetznerKey; + String? get serverProviderKey => _serverProviderKey; String? get cloudFlareKey => _cloudFlareKey; BackblazeCredential? get backblazeCredential => _backblazeCredential; ServerDomain? get serverDomain => _serverDomain; BackblazeBucket? get backblazeBucket => _backblazeBucket; - String? _hetznerKey; + String? _serverProviderKey; String? _cloudFlareKey; ServerHostingDetails? _serverDetails; BackblazeCredential? _backblazeCredential; ServerDomain? _serverDomain; BackblazeBucket? _backblazeBucket; - Future storeHetznerKey(final String value) async { + Future storeServerProviderKey(final String value) async { await _box.put(BNames.hetznerKey, value); - _hetznerKey = value; + _serverProviderKey = value; } Future storeCloudFlareKey(final String value) async { @@ -53,7 +53,7 @@ class ApiConfigModel { } void clear() { - _hetznerKey = null; + _serverProviderKey = null; _cloudFlareKey = null; _backblazeCredential = null; _serverDomain = null; @@ -62,7 +62,7 @@ class ApiConfigModel { } void init() { - _hetznerKey = _box.get(BNames.hetznerKey); + _serverProviderKey = _box.get(BNames.hetznerKey); _cloudFlareKey = _box.get(BNames.cloudFlareKey); _backblazeCredential = _box.get(BNames.backblazeCredential); _serverDomain = _box.get(BNames.serverDomain); diff --git a/lib/logic/models/hive/server_details.dart b/lib/logic/models/hive/server_details.dart index 3791c664..27e9829a 100644 --- a/lib/logic/models/hive/server_details.dart +++ b/lib/logic/models/hive/server_details.dart @@ -78,4 +78,6 @@ enum ServerProvider { unknown, @HiveField(1) hetzner, + @HiveField(2) + digitalOcean, } diff --git a/lib/main.dart b/lib/main.dart index f2c36392..8b521f8a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:selfprivacy/config/brand_colors.dart'; import 'package:selfprivacy/config/hive_config.dart'; import 'package:selfprivacy/theming/factory/app_theme_factory.dart'; -import 'package:selfprivacy/ui/pages/setup/initializing.dart'; +import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart'; import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart'; import 'package:selfprivacy/ui/pages/root_route.dart'; import 'package:wakelock/wakelock.dart'; diff --git a/lib/ui/components/not_ready_card/not_ready_card.dart b/lib/ui/components/not_ready_card/not_ready_card.dart index faa23381..fededcaf 100644 --- a/lib/ui/components/not_ready_card/not_ready_card.dart +++ b/lib/ui/components/not_ready_card/not_ready_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:selfprivacy/config/brand_colors.dart'; import 'package:selfprivacy/config/text_themes.dart'; -import 'package:selfprivacy/ui/pages/setup/initializing.dart'; +import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart'; import 'package:selfprivacy/utils/route_transitions/basic.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/lib/ui/pages/more/more.dart b/lib/ui/pages/more/more.dart index 5d487717..309746c5 100644 --- a/lib/ui/pages/more/more.dart +++ b/lib/ui/pages/more/more.dart @@ -11,7 +11,7 @@ import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart'; import 'package:selfprivacy/ui/pages/devices/devices.dart'; import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart'; import 'package:selfprivacy/ui/pages/server_storage/binds_migration/services_migration.dart'; -import 'package:selfprivacy/ui/pages/setup/initializing.dart'; +import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart'; import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart'; import 'package:selfprivacy/ui/pages/root_route.dart'; import 'package:selfprivacy/ui/pages/users/users.dart'; diff --git a/lib/ui/pages/setup/initializing.dart b/lib/ui/pages/setup/initializing/initializing.dart similarity index 92% rename from lib/ui/pages/setup/initializing.dart rename to lib/ui/pages/setup/initializing/initializing.dart index 02af3a59..2904cfb8 100644 --- a/lib/ui/pages/setup/initializing.dart +++ b/lib/ui/pages/setup/initializing/initializing.dart @@ -17,6 +17,7 @@ import 'package:selfprivacy/ui/components/brand_text/brand_text.dart'; import 'package:selfprivacy/ui/components/brand_timer/brand_timer.dart'; import 'package:selfprivacy/ui/components/progress_bar/progress_bar.dart'; import 'package:selfprivacy/ui/pages/root_route.dart'; +import 'package:selfprivacy/ui/pages/setup/initializing/provider_picker.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recovery_routing.dart'; import 'package:selfprivacy/utils/route_transitions/basic.dart'; @@ -139,52 +140,8 @@ class InitializingPage extends StatelessWidget { } Widget _stepHetzner(final ServerInstallationCubit serverInstallationCubit) => - BlocProvider( - create: (final context) => ProviderFormCubit( - serverInstallationCubit, - ), - child: Builder( - builder: (final context) { - final formCubitState = context.watch().state; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Image.asset( - 'assets/images/logos/hetzner.png', - width: 150, - ), - const SizedBox(height: 10), - BrandText.h2('initializing.connect_to_server'.tr()), - const SizedBox(height: 10), - BrandText.body2('initializing.place_where_data'.tr()), - const Spacer(), - CubitFormTextField( - formFieldCubit: context.read().apiKey, - textAlign: TextAlign.center, - scrollPadding: const EdgeInsets.only(bottom: 70), - decoration: const InputDecoration( - hintText: 'Hetzner API Token', - ), - ), - const Spacer(), - BrandButton.rised( - onPressed: formCubitState.isSubmitting - ? null - : () => context.read().trySubmit(), - text: 'basis.connect'.tr(), - ), - const SizedBox(height: 10), - BrandButton.text( - onPressed: () => _showModal( - context, - const _HowTo(fileName: 'how_hetzner'), - ), - title: 'initializing.how'.tr(), - ), - ], - ); - }, - ), + ProviderPicker( + serverInstallationCubit: serverInstallationCubit, ); void _showModal(final BuildContext context, final Widget widget) { diff --git a/lib/ui/pages/setup/initializing/provider_picker.dart b/lib/ui/pages/setup/initializing/provider_picker.dart new file mode 100644 index 00000000..c3cada82 --- /dev/null +++ b/lib/ui/pages/setup/initializing/provider_picker.dart @@ -0,0 +1,209 @@ +import 'package:cubit_form/cubit_form.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:selfprivacy/config/brand_theme.dart'; +import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart'; +import 'package:selfprivacy/logic/cubit/forms/setup/initializing/provider_form_cubit.dart'; +import 'package:selfprivacy/logic/models/hive/server_details.dart'; +import 'package:selfprivacy/ui/components/brand_bottom_sheet/brand_bottom_sheet.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; +import 'package:selfprivacy/ui/components/brand_md/brand_md.dart'; + +class ProviderPicker extends StatefulWidget { + const ProviderPicker({ + required this.serverInstallationCubit, + super.key, + }); + + final ServerInstallationCubit serverInstallationCubit; + + @override + State createState() => _ProviderPickerState(); +} + +class _ProviderPickerState extends State { + ServerProvider selectedProvider = ServerProvider.unknown; + + void setProvider(final ServerProvider provider) { + setState(() { + selectedProvider = provider; + }); + } + + @override + Widget build(final BuildContext context) { + switch (selectedProvider) { + case ServerProvider.unknown: + return ProviderSelectionPage( + callback: setProvider, + ); + + case ServerProvider.hetzner: + return ProviderInputDataPage( + serverInstallationCubit: widget.serverInstallationCubit, + providerInfo: ProviderPageInfo( + providerType: ServerProvider.hetzner, + pathToHow: 'hetzner_how', + image: Image.asset( + 'assets/images/logos/hetzner.png', + width: 150, + ), + ), + ); + + case ServerProvider.digitalOcean: + return ProviderInputDataPage( + serverInstallationCubit: widget.serverInstallationCubit, + providerInfo: ProviderPageInfo( + providerType: ServerProvider.digitalOcean, + pathToHow: 'hetzner_how', + image: Image.asset( + 'assets/images/logos/digital_ocean.png', + width: 150, + ), + ), + ); + } + } +} + +class ProviderPageInfo { + const ProviderPageInfo({ + required this.providerType, + required this.pathToHow, + required this.image, + }); + + final String pathToHow; + final Image image; + final ServerProvider providerType; +} + +class ProviderInputDataPage extends StatelessWidget { + const ProviderInputDataPage({ + required this.providerInfo, + required this.serverInstallationCubit, + super.key, + }); + + final ProviderPageInfo providerInfo; + final ServerInstallationCubit serverInstallationCubit; + + @override + Widget build(final BuildContext context) => BlocProvider( + create: (final context) => ProviderFormCubit( + serverInstallationCubit, + ), + child: Builder( + builder: (final context) { + final formCubitState = context.watch().state; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + providerInfo.image, + const SizedBox(height: 10), + Text( + 'initializing.connect_to_server'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + CubitFormTextField( + formFieldCubit: context.read().apiKey, + textAlign: TextAlign.center, + scrollPadding: const EdgeInsets.only(bottom: 70), + decoration: const InputDecoration( + hintText: 'Provider API Token', + ), + ), + const Spacer(), + FilledButton( + title: 'basis.connect'.tr(), + onPressed: () => formCubitState.isSubmitting + ? null + : () => context.read().trySubmit(), + ), + const SizedBox(height: 10), + OutlinedButton( + child: Text('initializing.how'.tr()), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (final BuildContext context) => BrandBottomSheet( + isExpended: true, + child: Padding( + padding: paddingH15V0, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + BrandMarkdown( + fileName: providerInfo.pathToHow, + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); +} + +class ProviderSelectionPage extends StatelessWidget { + const ProviderSelectionPage({ + required this.callback, + super.key, + }); + + final Function callback; + + @override + Widget build(final BuildContext context) => Column( + children: [ + Text( + 'initializing.select_provider'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 10), + Text( + 'initializing.place_where_data'.tr(), + ), + const SizedBox(height: 10), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 320, + ), + child: Row( + children: [ + InkWell( + onTap: () { + context.read().setServerProviderType(ServerProvider.hetzner); + callback(ServerProvider.hetzner); + }, + child: Image.asset( + 'assets/images/logos/hetzner.png', + width: 150, + ), + ), + const SizedBox( + width: 20, + ), + InkWell( + onTap: () { + context.read().setServerProviderType(ServerProvider.digitalOcean); + callback(ServerProvider.digitalOcean); + }, + child: Image.asset( + 'assets/images/logos/digital_ocean.png', + width: 150, + ), + ), + ], + ), + ), + ], + ); +} diff --git a/lib/ui/pages/setup/recovering/recovery_routing.dart b/lib/ui/pages/setup/recovering/recovery_routing.dart index 028b8618..4442d2be 100644 --- a/lib/ui/pages/setup/recovering/recovery_routing.dart +++ b/lib/ui/pages/setup/recovering/recovery_routing.dart @@ -47,7 +47,7 @@ class RecoveryRouting extends StatelessWidget { case RecoveryStep.oldToken: currentPage = const RecoverByOldToken(); break; - case RecoveryStep.hetznerToken: + case RecoveryStep.serverProviderToken: currentPage = const RecoveryHetznerConnected(); break; case RecoveryStep.serverSelection: