From 650e0e7376f9b5f701ee256c64f0af0ea7a497e0 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Mon, 6 Dec 2021 18:30:30 +0000 Subject: [PATCH 1/6] Add translation strings for backups --- assets/translations/en.json | 26 +++++++++++++++++++++++--- assets/translations/ru.json | 26 +++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index d624d8dc..84a07fd4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -99,7 +99,18 @@ "bottom_sheet": { "1": "Will save your day in case of incident: hackers attack, server deletion, etc.", "2": "3Gb/10Gb, last backup was yesterday {}" - } + }, + "reuploadKey": "Force reupload key", + "reuploadedKey": "Key reuploaded", + "initialize": "Initialize", + "waitingForRebuild": "You will be able to create your first backup in a few minutes.", + "restore": "Restore from backup", + "no_backups": "There are no backups yet", + "create_new": "Create a new backup", + "creating": "Creating a new backup: {}%", + "restoring": "Restoring from backup", + "error_pending": "Server returned error, check it below", + "restore_alert": "You are about to restore from backup created on {}. All current data will be lost. Are you sure?" } }, "not_ready_card": { @@ -223,7 +234,9 @@ "5": "Yes, purge all my tokens", "6": "Delete the server and volume?", "7": "Yes", - "8": "Remove task" + "8": "Remove task", + "9": "Reboot", + "yes": "Yes" }, "timer": { "sec": "{} sec" @@ -237,7 +250,14 @@ "serviceTurnOff": "Turn off", "serviceTurnOn": "Turn on", "jobAdded": "Job added", - "runJobs": "Run jobs" + "runJobs": "Run jobs", + "rebootSuccess": "Server is rebooting", + "rebootFailed": "Couldn't reboot the server. Check the app logs.", + "configPullFailed": "Failed to pull configuration upgrade. Started software upgrade anyways.", + "upgradeSuccess": "Server upgrade started", + "upgradeFailed": "Failed to upgrade server", + "upgradeServer": "Upgrade server", + "rebootServer": "Reboot server" }, "validations": { "required": "Required", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 9ea07d70..e36e8d2f 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -99,7 +99,18 @@ "bottom_sheet": { "1": "Выручит Вас в любой ситуации: хакерская атака, удаление сервера и т.д.", "2": "Использовано 3Gb из бесплатых 10Gb. Последнее копирование была сделано вчера в {}." - } + }, + "reuploadKey": "Принудительно обновить ключ", + "reuploadedKey": "Ключ на сервере обновлён", + "initialize": "Настроить", + "waitingForRebuild": "Через несколько минут можно будет создать первую копию.", + "restore": "Восстановить из копии", + "no_backups": "Резервных копий пока нет", + "create_new": "Создать новую копию", + "creating": "Создание копии: {}%", + "restoring": "Восстановление из копии", + "error_pending": "Сервер вернул ошибку: проверьте её ниже.", + "restore_alert": "Вы собираетесь восстановить из копии созданной {}. Все текущие данные будут потеряны. Вы уверены?" } }, "not_ready_card": { @@ -223,7 +234,9 @@ "5": "Да, сбросить", "6": "Удалить сервер и диск?", "7": "Да, удалить", - "8": "Удалить задачу" + "8": "Удалить задачу", + "9": "Перезагрузить", + "yes": "Да" }, "timer": { "sec": "{} сек" @@ -237,7 +250,14 @@ "serviceTurnOff": "Остановить", "serviceTurnOn": "Запустить", "jobAdded": "Задача добавленна", - "runJobs": "Запустите задачи" + "runJobs": "Запустите задачи", + "rebootSuccess": "Сервер перезагружается", + "rebootFailed": "Не удалось перезагрузить сервер, проверьте логи", + "configPullFailed": "Не удалось обновить конфигурацию сервера. Обновление ПО запущено.", + "upgradeSuccess": "Запущено обновление сервера", + "upgradeFailed": "Обновить сервер не вышло", + "upgradeServer": "Обновить сервер", + "rebootServer": "Перезагрузить сервер" }, "validations": { "required": "Обязательное поле.", From b40bea63d1a96a485c661759b5ae3c6c080252da Mon Sep 17 00:00:00 2001 From: Inex Code Date: Mon, 6 Dec 2021 18:31:19 +0000 Subject: [PATCH 2/6] Backups and server upgrade --- lib/config/bloc_config.dart | 5 +- lib/config/hive_config.dart | 3 + lib/config/md_files.dart | 1 + lib/logic/api_maps/backblaze.dart | 103 +++++++- lib/logic/api_maps/hetzner.dart | 4 +- lib/logic/api_maps/server.dart | 93 ++++++- .../app_config/app_config_repository.dart | 6 +- lib/logic/cubit/backups/backups_cubit.dart | 168 ++++++++++++ lib/logic/cubit/backups/backups_state.dart | 56 ++++ .../initializing/hetzner_form_cubit.dart | 3 +- .../initializing/root_user_form_cubit.dart | 4 +- lib/logic/cubit/jobs/jobs_cubit.dart | 24 ++ .../cubit/providers/providers_cubit.dart | 2 - lib/logic/cubit/users/users_cubit.dart | 2 +- lib/logic/get_it/api_config.dart | 10 + lib/logic/get_it/navigation.dart | 1 - lib/logic/models/backblaze_bucket.dart | 29 +++ lib/logic/models/backblaze_bucket.g.dart | 50 ++++ lib/logic/models/backup.dart | 48 ++++ lib/logic/models/backup.g.dart | 58 +++++ .../components/brand_alert/brand_alert.dart | 4 +- .../components/brand_button/brand_button.dart | 2 - .../brand_divider/brand_divider.dart | 1 - lib/ui/components/error/error.dart | 1 - .../components/jobs_content/jobs_content.dart | 29 +++ .../pre_styled_buttons.dart | 1 - .../pages/backup_details/backup_details.dart | 239 ++++++++++++++++++ lib/ui/pages/backup_details/header.dart | 70 +++++ .../pages/more/app_settings/app_setting.dart | 4 +- lib/ui/pages/more/more.dart | 2 +- lib/ui/pages/providers/providers.dart | 19 +- lib/utils/extensions/elevation_extension.dart | 1 - lib/utils/named_font_weight.dart | 2 +- lib/utils/password_generator.dart | 2 +- lib/utils/route_transitions/slide_right.dart | 3 +- lib/utils/ui_helpers.dart | 2 +- 36 files changed, 1012 insertions(+), 40 deletions(-) create mode 100644 lib/logic/cubit/backups/backups_cubit.dart create mode 100644 lib/logic/cubit/backups/backups_state.dart create mode 100644 lib/logic/models/backblaze_bucket.dart create mode 100644 lib/logic/models/backblaze_bucket.g.dart create mode 100644 lib/logic/models/backup.dart create mode 100644 lib/logic/models/backup.g.dart create mode 100644 lib/ui/pages/backup_details/backup_details.dart create mode 100644 lib/ui/pages/backup_details/header.dart diff --git a/lib/config/bloc_config.dart b/lib/config/bloc_config.dart index 00f066a3..19f1eb67 100644 --- a/lib/config/bloc_config.dart +++ b/lib/config/bloc_config.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; import 'package:selfprivacy/logic/cubit/app_config/app_config_cubit.dart'; +import 'package:selfprivacy/logic/cubit/backups/backups_cubit.dart'; import 'package:selfprivacy/logic/cubit/jobs/jobs_cubit.dart'; import 'package:selfprivacy/logic/cubit/providers/providers_cubit.dart'; import 'package:selfprivacy/logic/cubit/services/services_cubit.dart'; @@ -18,6 +19,7 @@ class BlocAndProviderConfig extends StatelessWidget { var usersCubit = UsersCubit(); var appConfigCubit = AppConfigCubit()..load(); var servicesCubit = ServicesCubit(appConfigCubit); + var backupsCubit = BackupsCubit(appConfigCubit); return MultiProvider( providers: [ BlocProvider( @@ -26,10 +28,11 @@ class BlocAndProviderConfig extends StatelessWidget { isOnbordingShowing: true, )..load(), ), - BlocProvider(lazy: false, create: (_) => appConfigCubit), + BlocProvider(create: (_) => appConfigCubit, lazy: false), BlocProvider(create: (_) => ProvidersCubit()), BlocProvider(create: (_) => usersCubit..load(), lazy: false), BlocProvider(create: (_) => servicesCubit..load(), lazy: false), + BlocProvider(create: (_) => backupsCubit..load(), lazy: false), BlocProvider( create: (_) => JobsCubit(usersCubit: usersCubit, servicesCubit: servicesCubit), diff --git a/lib/config/hive_config.dart b/lib/config/hive_config.dart index a9659869..26b5f995 100644 --- a/lib/config/hive_config.dart +++ b/lib/config/hive_config.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:selfprivacy/logic/models/backblaze_bucket.dart'; import 'package:selfprivacy/logic/models/backblaze_credential.dart'; import 'package:selfprivacy/logic/models/cloudflare_domain.dart'; import 'package:selfprivacy/logic/models/server_details.dart'; @@ -16,6 +17,7 @@ class HiveConfig { Hive.registerAdapter(HetznerServerDetailsAdapter()); Hive.registerAdapter(CloudFlareDomainAdapter()); Hive.registerAdapter(BackblazeCredentialAdapter()); + Hive.registerAdapter(BackblazeBucketAdapter()); Hive.registerAdapter(HetznerDataBaseAdapter()); await Hive.openBox(BNames.appSettings); @@ -62,6 +64,7 @@ class BNames { static String hasFinalChecked = 'hasFinalChecked'; static String isServerStarted = 'isServerStarted'; static String backblazeKey = 'backblazeKey'; + static String backblazeBucket = 'backblazeBucket'; static String isLoading = 'isLoading'; static String isServerResetedFirstTime = 'isServerResetedFirstTime'; static String isServerResetedSecondTime = 'isServerResetedSecondTime'; diff --git a/lib/config/md_files.dart b/lib/config/md_files.dart index e69de29b..8b137891 100644 --- a/lib/config/md_files.dart +++ b/lib/config/md_files.dart @@ -0,0 +1 @@ + diff --git a/lib/logic/api_maps/backblaze.dart b/lib/logic/api_maps/backblaze.dart index 2a9888cd..695a2ec6 100644 --- a/lib/logic/api_maps/backblaze.dart +++ b/lib/logic/api_maps/backblaze.dart @@ -2,6 +2,22 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/api_maps/api_map.dart'; +import 'package:selfprivacy/logic/models/backblaze_credential.dart'; + +class BackblazeApiAuth { + BackblazeApiAuth({required this.authorizationToken, required this.apiUrl}); + + final String authorizationToken; + final String apiUrl; +} + +class BackblazeApplicationKey { + BackblazeApplicationKey( + {required this.applicationKeyId, required this.applicationKey}); + + final String applicationKeyId; + final String applicationKey; +} class BackblazeApi extends ApiMap { BackblazeApi({this.hasLoger = false, this.isWithToken = true}); @@ -24,6 +40,29 @@ class BackblazeApi extends ApiMap { @override String rootAddress = 'https://api.backblazeb2.com/b2api/v2/'; + String apiPrefix = '/b2api/v2'; + + Future getAuthorizationToken() async { + var client = await getClient(); + var backblazeCredential = getIt().backblazeCredential; + if (backblazeCredential == null) { + throw Exception('Backblaze credential is null'); + } + final String encodedApiKey = encodedBackblazeKey( + backblazeCredential.keyId, backblazeCredential.applicationKey); + var response = await client.get( + 'b2_authorize_account', + options: Options(headers: {'Authorization': 'Basic $encodedApiKey'}), + ); + if (response.statusCode != 200) { + throw Exception('code: ${response.statusCode}'); + } + return BackblazeApiAuth( + authorizationToken: response.data['authorizationToken'], + apiUrl: response.data['apiUrl'], + ); + } + Future isValid(String encodedApiKey) async { var client = await getClient(); Response response = await client.get( @@ -32,7 +71,10 @@ class BackblazeApi extends ApiMap { ); close(client); if (response.statusCode == HttpStatus.ok) { - return true; + if (response.data['allowed']['capabilities'].contains('listBuckets')) { + return true; + } + return false; } else if (response.statusCode == HttpStatus.unauthorized) { return false; } else { @@ -40,6 +82,65 @@ class BackblazeApi extends ApiMap { } } + // Create bucket + Future createBucket(String bucketName) async { + final auth = await getAuthorizationToken(); + var backblazeCredential = getIt().backblazeCredential; + var client = await getClient(); + client.options.baseUrl = auth.apiUrl; + var response = await client.post( + '$apiPrefix/b2_create_bucket', + data: { + 'accountId': backblazeCredential!.keyId, + 'bucketName': bucketName, + 'bucketType': 'allPrivate', + 'lifecycleRules': [ + { + "daysFromHidingToDeleting": 30, + "daysFromUploadingToHiding": null, + "fileNamePrefix": "" + } + ], + }, + options: Options( + headers: {'Authorization': auth.authorizationToken}, + ), + ); + close(client); + if (response.statusCode == HttpStatus.ok) { + return response.data['bucketId']; + } else { + throw Exception('code: ${response.statusCode}'); + } + } + + // Create a limited capability key with access to the given bucket + Future createKey(String bucketId) async { + final auth = await getAuthorizationToken(); + var client = await getClient(); + client.options.baseUrl = auth.apiUrl; + var response = await client.post( + '$apiPrefix/b2_create_key', + data: { + 'accountId': getIt().backblazeCredential!.keyId, + 'bucketId': bucketId, + 'capabilities': ['listBuckets', 'listFiles', 'readFiles', 'writeFiles'], + 'keyName': 'selfprivacy-restricted-server-key', + }, + options: Options( + headers: {'Authorization': auth.authorizationToken}, + ), + ); + close(client); + if (response.statusCode == HttpStatus.ok) { + return BackblazeApplicationKey( + applicationKeyId: response.data['applicationKeyId'], + applicationKey: response.data['applicationKey']); + } else { + throw Exception('code: ${response.statusCode}'); + } + } + @override bool hasLoger; diff --git a/lib/logic/api_maps/hetzner.dart b/lib/logic/api_maps/hetzner.dart index 48d4d69e..33cda77d 100644 --- a/lib/logic/api_maps/hetzner.dart +++ b/lib/logic/api_maps/hetzner.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/api_maps/api_map.dart'; -import 'package:selfprivacy/logic/models/backblaze_credential.dart'; import 'package:selfprivacy/logic/models/hetzner_server_info.dart'; import 'package:selfprivacy/logic/models/server_details.dart'; import 'package:selfprivacy/logic/models/user.dart'; @@ -94,7 +93,6 @@ class HetznerApi extends ApiMap { required User rootUser, required String domainName, required HetznerDataBase dataBase, - required BackblazeCredential backblazeCredential, }) async { var client = await getClient(); @@ -122,7 +120,7 @@ class HetznerApi extends ApiMap { /// check the branch name, it could be "development" or "master". var data = jsonDecode( - '''{"name":"$domainName","server_type":"cx11","start_after_create":false,"image":"ubuntu-20.04", "volumes":[$dbId], "networks":[], "user_data":"#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} PASSWORD=${rootUser.password} CF_TOKEN=$cloudFlareKey DB_PASSWORD=$dbPassword BACKBLAZE_KEY_ID=${backblazeCredential.keyId} BACKBLAZE_ACCOUNT_KEY=${backblazeCredential.applicationKey} API_TOKEN=$apiToken HOSTNAME=$hostname bash 2>&1 | tee /tmp/infect.log","labels":{},"automount":true, "location": "fsn1"}'''); + '''{"name":"$domainName","server_type":"cx11","start_after_create":false,"image":"ubuntu-20.04", "volumes":[$dbId], "networks":[], "user_data":"#cloud-config\\nruncmd:\\n- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/rolling-testing/nixos-infect | PROVIDER=hetzner NIX_CHANNEL=nixos-21.05 DOMAIN=$domainName LUSER=${rootUser.login} PASSWORD=${rootUser.password} CF_TOKEN=$cloudFlareKey DB_PASSWORD=$dbPassword API_TOKEN=$apiToken HOSTNAME=$hostname bash 2>&1 | tee /tmp/infect.log","labels":{},"automount":true, "location": "fsn1"}'''); Response serverCreateResponse = await client.post( '/servers', diff --git a/lib/logic/api_maps/server.dart b/lib/logic/api_maps/server.dart index ea0d9d40..7ec2e5c7 100644 --- a/lib/logic/api_maps/server.dart +++ b/lib/logic/api_maps/server.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/common_enum/common_enum.dart'; +import 'package:selfprivacy/logic/models/backblaze_bucket.dart'; +import 'package:selfprivacy/logic/models/backup.dart'; import 'package:selfprivacy/logic/models/user.dart'; import 'api_map.dart'; @@ -23,7 +25,7 @@ class ServerApi extends ApiMap { var apiToken = getIt().hetznerServer?.apiToken; options = BaseOptions(baseUrl: 'https://api.$domainName', headers: { - 'Authorization': 'Bearer ${apiToken}', + 'Authorization': 'Bearer $apiToken', }); } @@ -123,6 +125,95 @@ class ServerApi extends ApiMap { ServiceTypes.socialNetwork: response.data['pleroma'] == 0, }; } + + Future uploadBackblazeConfig(BackblazeBucket bucket) async { + var client = await getClient(); + client.put( + '/services/restic/backblaze/config', + data: { + 'accountId': bucket.applicationKeyId, + 'accountKey': bucket.applicationKey, + 'bucket': bucket.bucketName, + }, + ); + client.close(); + } + + Future startBackup() async { + var client = await getClient(); + client.put('/services/restic/backup/create'); + client.close(); + } + + Future> getBackups() async { + Response response; + + var client = await getClient(); + try { + response = await client.get( + '/services/restic/backup/list', + ); + return response.data.map((e) => Backup.fromJson(e)).toList(); + } catch (e) { + print(e); + } + close(client); + return []; + } + + Future getBackupStatus() async { + Response response; + + var client = await getClient(); + try { + response = await client.get( + '/services/restic/backup/status', + ); + return BackupStatus.fromJson(response.data); + } catch (e) { + print(e); + } + close(client); + + return BackupStatus( + status: BackupStatusEnum.error, + errorMessage: 'Network error', + progress: 0, + ); + } + + Future forceBackupListReload() async { + var client = await getClient(); + client.get('/services/restic/backup/reload'); + client.close(); + } + + Future restoreBackup(String backupId) async { + var client = await getClient(); + client.put('/services/restic/backup/restore', data: {'backupId': backupId}); + client.close(); + } + + Future pullConfigurationUpdate() async { + var client = await getClient(); + Response response = await client.get('/system/configuration/pull'); + close(client); + return response.statusCode == HttpStatus.ok; + } + + Future reboot() async { + var client = await getClient(); + Response response = await client.get('/system/reboot'); + client.close(); + return response.statusCode == HttpStatus.ok; + } + + Future upgrade() async { + var client = await getClient(); + Response response = await client.get('/system/configuration/upgrade'); + client.close(); + return response.statusCode == HttpStatus.ok; + } } extension UrlServerExt on ServiceTypes { diff --git a/lib/logic/cubit/app_config/app_config_repository.dart b/lib/logic/cubit/app_config/app_config_repository.dart index eb811c45..025c2f6d 100644 --- a/lib/logic/cubit/app_config/app_config_repository.dart +++ b/lib/logic/cubit/app_config/app_config_repository.dart @@ -4,13 +4,11 @@ import 'package:selfprivacy/config/hive_config.dart'; import 'package:selfprivacy/logic/api_maps/cloudflare.dart'; import 'package:selfprivacy/logic/api_maps/hetzner.dart'; import 'package:selfprivacy/logic/api_maps/server.dart'; -import 'package:selfprivacy/logic/get_it/api_config.dart'; import 'package:selfprivacy/logic/models/backblaze_credential.dart'; import 'package:selfprivacy/logic/models/cloudflare_domain.dart'; import 'package:selfprivacy/logic/models/server_details.dart'; import 'package:selfprivacy/logic/models/user.dart'; import 'package:selfprivacy/config/get_it_config.dart'; -import 'package:selfprivacy/logic/get_it/console.dart'; import 'package:selfprivacy/logic/models/message.dart'; import 'package:basic_utils/basic_utils.dart'; import 'package:selfprivacy/ui/components/action_button/action_button.dart'; @@ -127,7 +125,6 @@ class AppConfigRepository { rootUser: rootUser, domainName: domainName, dataBase: dataBase, - backblazeCredential: backblazeCredential, ); saveServerDetails(serverDetails); onSuccess(serverDetails); @@ -138,7 +135,7 @@ class AppConfigRepository { BrandAlert( title: 'modals.1'.tr(), contentText: 'modals.2'.tr(), - acitons: [ + actions: [ ActionButton( text: 'basis.delete'.tr(), isRed: true, @@ -151,7 +148,6 @@ class AppConfigRepository { rootUser: rootUser, domainName: domainName, dataBase: dataBase, - backblazeCredential: backblazeCredential, ); await saveServerDetails(serverDetails); diff --git a/lib/logic/cubit/backups/backups_cubit.dart b/lib/logic/cubit/backups/backups_cubit.dart new file mode 100644 index 00000000..71d6ce15 --- /dev/null +++ b/lib/logic/cubit/backups/backups_cubit.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart'; +import 'package:selfprivacy/logic/models/backblaze_bucket.dart'; +import 'package:selfprivacy/logic/models/backup.dart'; +import 'package:selfprivacy/logic/api_maps/server.dart'; +import 'package:selfprivacy/logic/api_maps/backblaze.dart'; + +part 'backups_state.dart'; + +class BackupsCubit extends AppConfigDependendCubit { + BackupsCubit(AppConfigCubit appConfigCubit) + : super(appConfigCubit, BackupsState(preventActions: true)); + + final api = ServerApi(); + final backblaze = BackblazeApi(); + + Future load() async { + if (appConfigCubit.state is AppConfigFinished) { + final bucket = getIt().backblazeBucket; + if (bucket == null) { + emit(BackupsState( + isInitialized: false, preventActions: false, refreshing: false)); + } else { + final status = await api.getBackupStatus(); + switch (status.status) { + case BackupStatusEnum.noKey: + case BackupStatusEnum.notInitialized: + emit(BackupsState( + backups: [], + isInitialized: true, + preventActions: false, + progress: 0, + status: status.status, + refreshing: false, + )); + break; + case BackupStatusEnum.initializing: + emit(BackupsState( + backups: [], + isInitialized: true, + preventActions: false, + progress: 0, + status: status.status, + refreshTimer: Duration(seconds: 10), + refreshing: false, + )); + break; + case BackupStatusEnum.initialized: + case BackupStatusEnum.error: + final backups = await api.getBackups(); + emit(BackupsState( + backups: backups, + isInitialized: true, + preventActions: false, + progress: status.progress, + status: status.status, + error: status.errorMessage, + refreshing: false, + )); + break; + case BackupStatusEnum.backingUp: + case BackupStatusEnum.restoring: + final backups = await api.getBackups(); + emit(BackupsState( + backups: backups, + isInitialized: true, + preventActions: true, + progress: status.progress, + status: status.status, + error: status.errorMessage, + refreshTimer: Duration(seconds: 5), + refreshing: false, + )); + break; + default: + emit(BackupsState()); + } + Timer(state.refreshTimer, () => updateBackups(useTimer: true)); + } + } + } + + Future createBucket() async { + emit(state.copyWith(preventActions: true)); + final domain = + appConfigCubit.state.cloudFlareDomain!.domainName.replaceAll('.', '-'); + final serverId = appConfigCubit.state.hetznerServer!.id; + var bucketName = 'selfprivacy-$domain-$serverId'; + // If bucket name is too long, shorten it + if (bucketName.length > 49) { + bucketName = bucketName.substring(0, 49); + } + final bucketId = await backblaze.createBucket(bucketName); + + final key = await backblaze.createKey(bucketId); + final bucket = BackblazeBucket( + bucketId: bucketId, + bucketName: bucketName, + applicationKey: key.applicationKey, + applicationKeyId: key.applicationKeyId); + + await getIt().storeBackblazeBucket(bucket); + await api.uploadBackblazeConfig(bucket); + await updateBackups(); + + emit(state.copyWith(isInitialized: true, preventActions: false)); + } + + Future reuploadKey() async { + emit(state.copyWith(preventActions: true)); + final bucket = getIt().backblazeBucket; + if (bucket == null) { + emit(state.copyWith(isInitialized: false)); + } else { + await api.uploadBackblazeConfig(bucket); + emit(state.copyWith(isInitialized: true, preventActions: false)); + getIt().showSnackBar('providers.backup.reuploadedKey'); + } + } + + Duration refreshTimeFromState(BackupStatusEnum status) { + switch (status) { + case BackupStatusEnum.backingUp: + case BackupStatusEnum.restoring: + return Duration(seconds: 5); + case BackupStatusEnum.initializing: + return Duration(seconds: 10); + default: + return Duration(seconds: 60); + } + } + + Future updateBackups({bool useTimer = false}) async { + emit(state.copyWith(refreshing: true)); + final backups = await api.getBackups(); + final status = await api.getBackupStatus(); + emit(state.copyWith( + backups: backups, + progress: status.progress, + status: status.status, + error: status.errorMessage, + refreshTimer: refreshTimeFromState(status.status), + refreshing: false, + )); + if (useTimer) + Timer(state.refreshTimer, () => updateBackups(useTimer: true)); + } + + Future createBackup() async { + emit(state.copyWith(preventActions: true)); + await api.startBackup(); + await updateBackups(); + emit(state.copyWith(preventActions: false)); + } + + Future restoreBackup(String backupId) async { + emit(state.copyWith(preventActions: true)); + await api.restoreBackup(backupId); + emit(state.copyWith(preventActions: false)); + } + + @override + void clear() async { + emit(BackupsState()); + } +} diff --git a/lib/logic/cubit/backups/backups_state.dart b/lib/logic/cubit/backups/backups_state.dart new file mode 100644 index 00000000..6b0bc5e3 --- /dev/null +++ b/lib/logic/cubit/backups/backups_state.dart @@ -0,0 +1,56 @@ +part of 'backups_cubit.dart'; + +class BackupsState extends AppConfigDependendState { + const BackupsState({ + this.isInitialized = false, + this.backups = const [], + this.progress = 0.0, + this.status = BackupStatusEnum.noKey, + this.preventActions = true, + this.error = "", + this.refreshTimer = const Duration(seconds: 60), + this.refreshing = true, + }); + + final bool isInitialized; + final List backups; + final double progress; + final BackupStatusEnum status; + final bool preventActions; + final String error; + final Duration refreshTimer; + final bool refreshing; + + @override + List get props => [ + isInitialized, + backups, + progress, + preventActions, + status, + error, + refreshTimer, + refreshing + ]; + + BackupsState copyWith({ + bool? isInitialized, + List? backups, + double? progress, + BackupStatusEnum? status, + bool? preventActions, + String? error, + Duration? refreshTimer, + bool? refreshing, + }) => + BackupsState( + isInitialized: isInitialized ?? this.isInitialized, + backups: backups ?? this.backups, + progress: progress ?? this.progress, + status: status ?? this.status, + preventActions: preventActions ?? this.preventActions, + error: error ?? this.error, + refreshTimer: refreshTimer ?? this.refreshTimer, + refreshing: refreshing ?? this.refreshing, + ); +} diff --git a/lib/logic/cubit/forms/initializing/hetzner_form_cubit.dart b/lib/logic/cubit/forms/initializing/hetzner_form_cubit.dart index 4824ff2e..26ee8593 100644 --- a/lib/logic/cubit/forms/initializing/hetzner_form_cubit.dart +++ b/lib/logic/cubit/forms/initializing/hetzner_form_cubit.dart @@ -15,7 +15,8 @@ class HetznerFormCubit extends FormCubit { RequiredStringValidation('validations.required'.tr()), ValidationModel( (s) => regExp.hasMatch(s), 'validations.key_format'.tr()), - LegnthStringValidationWithLenghShowing(64, 'validations.length'.tr(args: ["64"])) + LegnthStringValidationWithLenghShowing( + 64, 'validations.length'.tr(args: ["64"])) ], ); diff --git a/lib/logic/cubit/forms/initializing/root_user_form_cubit.dart b/lib/logic/cubit/forms/initializing/root_user_form_cubit.dart index 5fb172a2..41b26582 100644 --- a/lib/logic/cubit/forms/initializing/root_user_form_cubit.dart +++ b/lib/logic/cubit/forms/initializing/root_user_form_cubit.dart @@ -25,8 +25,8 @@ class RootUserFormCubit extends FormCubit { initalValue: '', validations: [ RequiredStringValidation('validations.required'.tr()), - ValidationModel( - (s) => passwordRegExp.hasMatch(s), 'validations.invalid_format'.tr()), + ValidationModel((s) => passwordRegExp.hasMatch(s), + 'validations.invalid_format'.tr()), ], ); diff --git a/lib/logic/cubit/jobs/jobs_cubit.dart b/lib/logic/cubit/jobs/jobs_cubit.dart index 0a6c4a12..d48c6fdd 100644 --- a/lib/logic/cubit/jobs/jobs_cubit.dart +++ b/lib/logic/cubit/jobs/jobs_cubit.dart @@ -68,6 +68,29 @@ class JobsCubit extends Cubit { } } + Future rebootServer() async { + final isSuccessful = await api.reboot(); + if (isSuccessful) { + getIt().showSnackBar('jobs.rebootSuccess'.tr()); + } else { + getIt().showSnackBar('jobs.rebootFailed'.tr()); + } + } + + Future upgradeServer() async { + final isPullSuccessful = await api.pullConfigurationUpdate(); + final isSuccessful = await api.upgrade(); + if (isSuccessful) { + if (!isPullSuccessful) { + getIt().showSnackBar('jobs.configPullFailed'.tr()); + } else { + getIt().showSnackBar('jobs.upgradeSuccess'.tr()); + } + } else { + getIt().showSnackBar('jobs.upgradeFailed'.tr()); + } + } + Future applyAll() async { if (state is JobsStateWithJobs) { var jobs = (state as JobsStateWithJobs).jobList; @@ -89,6 +112,7 @@ class JobsCubit extends Cubit { } usersCubit.addUsers(newUsers); + await api.pullConfigurationUpdate(); await api.apply(); if (hasServiceJobs) { await servicesCubit.load(); diff --git a/lib/logic/cubit/providers/providers_cubit.dart b/lib/logic/cubit/providers/providers_cubit.dart index 9482a194..5f92e225 100644 --- a/lib/logic/cubit/providers/providers_cubit.dart +++ b/lib/logic/cubit/providers/providers_cubit.dart @@ -16,5 +16,3 @@ class ProvidersCubit extends Cubit { emit(newState); } } - - diff --git a/lib/logic/cubit/users/users_cubit.dart b/lib/logic/cubit/users/users_cubit.dart index 3234a875..a38db016 100644 --- a/lib/logic/cubit/users/users_cubit.dart +++ b/lib/logic/cubit/users/users_cubit.dart @@ -13,7 +13,7 @@ class UsersCubit extends Cubit { void load() async { var loadedUsers = box.values.toList(); - + if (loadedUsers.isNotEmpty) { emit(UsersState(loadedUsers)); } diff --git a/lib/logic/get_it/api_config.dart b/lib/logic/get_it/api_config.dart index 53759e2e..1bc15eb1 100644 --- a/lib/logic/get_it/api_config.dart +++ b/lib/logic/get_it/api_config.dart @@ -1,5 +1,6 @@ import 'package:hive/hive.dart'; import 'package:selfprivacy/config/hive_config.dart'; +import 'package:selfprivacy/logic/models/backblaze_bucket.dart'; import 'package:selfprivacy/logic/models/backblaze_credential.dart'; import 'package:selfprivacy/logic/models/cloudflare_domain.dart'; import 'package:selfprivacy/logic/models/server_details.dart'; @@ -12,12 +13,14 @@ class ApiConfigModel { String? get cloudFlareKey => _cloudFlareKey; BackblazeCredential? get backblazeCredential => _backblazeCredential; CloudFlareDomain? get cloudFlareDomain => _cloudFlareDomain; + BackblazeBucket? get backblazeBucket => _backblazeBucket; String? _hetznerKey; String? _cloudFlareKey; HetznerServerDetails? _hetznerServer; BackblazeCredential? _backblazeCredential; CloudFlareDomain? _cloudFlareDomain; + BackblazeBucket? _backblazeBucket; Future storeHetznerKey(String value) async { await _box.put(BNames.hetznerKey, value); @@ -45,12 +48,18 @@ class ApiConfigModel { _hetznerServer = value; } + Future storeBackblazeBucket(BackblazeBucket value) async { + await _box.put(BNames.backblazeBucket, value); + _backblazeBucket = value; + } + clear() { _hetznerKey = null; _cloudFlareKey = null; _backblazeCredential = null; _cloudFlareDomain = null; _hetznerServer = null; + _backblazeBucket = null; } void init() { @@ -60,5 +69,6 @@ class ApiConfigModel { _backblazeCredential = _box.get(BNames.backblazeKey); _cloudFlareDomain = _box.get(BNames.cloudFlareDomain); _hetznerServer = _box.get(BNames.hetznerServer); + _backblazeBucket = _box.get(BNames.backblazeBucket); } } diff --git a/lib/logic/get_it/navigation.dart b/lib/logic/get_it/navigation.dart index 4f06c843..0a235434 100644 --- a/lib/logic/get_it/navigation.dart +++ b/lib/logic/get_it/navigation.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:selfprivacy/config/brand_colors.dart'; import 'package:selfprivacy/config/text_themes.dart'; diff --git a/lib/logic/models/backblaze_bucket.dart b/lib/logic/models/backblaze_bucket.dart new file mode 100644 index 00000000..e1e0dd3f --- /dev/null +++ b/lib/logic/models/backblaze_bucket.dart @@ -0,0 +1,29 @@ +import 'package:hive/hive.dart'; + +part 'backblaze_bucket.g.dart'; + +@HiveType(typeId: 6) +class BackblazeBucket { + BackblazeBucket( + {required this.bucketId, + required this.bucketName, + required this.applicationKeyId, + required this.applicationKey}); + + @HiveField(0) + final String bucketId; + + @HiveField(1) + final String applicationKeyId; + + @HiveField(2) + final String applicationKey; + + @HiveField(3) + final String bucketName; + + @override + String toString() { + return '$bucketName'; + } +} diff --git a/lib/logic/models/backblaze_bucket.g.dart b/lib/logic/models/backblaze_bucket.g.dart new file mode 100644 index 00000000..18802bc0 --- /dev/null +++ b/lib/logic/models/backblaze_bucket.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backblaze_bucket.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class BackblazeBucketAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + BackblazeBucket read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return BackblazeBucket( + bucketId: fields[0] as String, + bucketName: fields[3] as String, + applicationKeyId: fields[1] as String, + applicationKey: fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, BackblazeBucket obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.bucketId) + ..writeByte(1) + ..write(obj.applicationKeyId) + ..writeByte(2) + ..write(obj.applicationKey) + ..writeByte(3) + ..write(obj.bucketName); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BackblazeBucketAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/logic/models/backup.dart b/lib/logic/models/backup.dart new file mode 100644 index 00000000..7edff192 --- /dev/null +++ b/lib/logic/models/backup.dart @@ -0,0 +1,48 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'backup.g.dart'; + +@JsonSerializable() +class Backup { + Backup({required this.time, required this.id}); + + // Time of the backup + final DateTime time; + @JsonKey(name: 'short_id') + final String id; + + factory Backup.fromJson(Map json) => _$BackupFromJson(json); +} + +enum BackupStatusEnum { + @JsonValue('NO_KEY') + noKey, + @JsonValue('NOT_INITIALIZED') + notInitialized, + @JsonValue('INITIALIZED') + initialized, + @JsonValue('BACKING_UP') + backingUp, + @JsonValue('RESTORING') + restoring, + @JsonValue('ERROR') + error, + @JsonValue('INITIALIZING') + initializing, +} + +@JsonSerializable() +class BackupStatus { + BackupStatus( + {required this.status, + required this.progress, + required this.errorMessage}); + + final BackupStatusEnum status; + final double progress; + @JsonKey(name: 'error_message') + final String errorMessage; + + factory BackupStatus.fromJson(Map json) => + _$BackupStatusFromJson(json); +} diff --git a/lib/logic/models/backup.g.dart b/lib/logic/models/backup.g.dart new file mode 100644 index 00000000..ddeff755 --- /dev/null +++ b/lib/logic/models/backup.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backup.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Backup _$BackupFromJson(Map json) { + return Backup( + time: DateTime.parse(json['time'] as String), + id: json['short_id'] as String, + ); +} + +BackupStatus _$BackupStatusFromJson(Map json) { + return BackupStatus( + status: _$enumDecode(_$BackupStatusEnumEnumMap, json['status']), + progress: (json['progress'] as num).toDouble(), + errorMessage: json['error_message'] as String, + ); +} + +K _$enumDecode( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + throw ArgumentError( + 'A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + return enumValues.entries.singleWhere( + (e) => e.value == source, + orElse: () { + if (unknownValue == null) { + throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ); + } + return MapEntry(unknownValue, enumValues.values.first); + }, + ).key; +} + +const _$BackupStatusEnumEnumMap = { + BackupStatusEnum.noKey: 'NO_KEY', + BackupStatusEnum.notInitialized: 'NOT_INITIALIZED', + BackupStatusEnum.initialized: 'INITIALIZED', + BackupStatusEnum.backingUp: 'BACKING_UP', + BackupStatusEnum.restoring: 'RESTORING', + BackupStatusEnum.error: 'ERROR', + BackupStatusEnum.initializing: 'INITIALIZING', +}; diff --git a/lib/ui/components/brand_alert/brand_alert.dart b/lib/ui/components/brand_alert/brand_alert.dart index 6f1a1b0f..e4a8f04c 100644 --- a/lib/ui/components/brand_alert/brand_alert.dart +++ b/lib/ui/components/brand_alert/brand_alert.dart @@ -5,11 +5,11 @@ class BrandAlert extends AlertDialog { Key? key, String? title, String? contentText, - List? acitons, + List? actions, }) : super( key: key, title: title != null ? Text(title) : null, content: title != null ? Text(contentText!) : null, - actions: acitons, + actions: actions, ); } diff --git a/lib/ui/components/brand_button/brand_button.dart b/lib/ui/components/brand_button/brand_button.dart index d3ed28b5..86020121 100644 --- a/lib/ui/components/brand_button/brand_button.dart +++ b/lib/ui/components/brand_button/brand_button.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:selfprivacy/config/brand_colors.dart'; import 'package:selfprivacy/ui/components/brand_text/brand_text.dart'; diff --git a/lib/ui/components/brand_divider/brand_divider.dart b/lib/ui/components/brand_divider/brand_divider.dart index f031254f..bd3d9c92 100644 --- a/lib/ui/components/brand_divider/brand_divider.dart +++ b/lib/ui/components/brand_divider/brand_divider.dart @@ -13,4 +13,3 @@ class BrandDivider extends StatelessWidget { ); } } - diff --git a/lib/ui/components/error/error.dart b/lib/ui/components/error/error.dart index 4046106e..ed46f547 100644 --- a/lib/ui/components/error/error.dart +++ b/lib/ui/components/error/error.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; class BrandError extends StatelessWidget { const BrandError({Key? key, this.error, this.stackTrace}) : super(key: key); diff --git a/lib/ui/components/jobs_content/jobs_content.dart b/lib/ui/components/jobs_content/jobs_content.dart index 10879f0e..8f766355 100644 --- a/lib/ui/components/jobs_content/jobs_content.dart +++ b/lib/ui/components/jobs_content/jobs_content.dart @@ -3,8 +3,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:selfprivacy/config/brand_colors.dart'; import 'package:selfprivacy/config/brand_theme.dart'; +import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/cubit/jobs/jobs_cubit.dart'; import 'package:selfprivacy/logic/cubit/users/users_cubit.dart'; +import 'package:selfprivacy/ui/components/action_button/action_button.dart'; +import 'package:selfprivacy/ui/components/brand_alert/brand_alert.dart'; import 'package:selfprivacy/ui/components/brand_button/brand_button.dart'; import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart'; import 'package:selfprivacy/ui/components/brand_loader/brand_loader.dart'; @@ -22,6 +25,32 @@ class JobsContent extends StatelessWidget { widgets = [ SizedBox(height: 80), Center(child: BrandText.body1('jobs.empty'.tr())), + SizedBox(height: 80), + BrandButton.rised( + onPressed: () => context.read().upgradeServer(), + text: 'jobs.upgradeServer'.tr(), + ), + SizedBox(height: 10), + BrandButton.text( + onPressed: () { + var nav = getIt(); + nav.showPopUpDialog(BrandAlert( + title: 'jobs.rebootServer'.tr(), + contentText: 'modals.3'.tr(), + actions: [ + ActionButton( + text: 'basis.cancel'.tr(), + ), + ActionButton( + onPressed: () => + {context.read().rebootServer()}, + text: 'modals.9'.tr(), + ) + ], + )); + }, + title: 'jobs.rebootServer'.tr(), + ), ]; } else if (state is JobsStateLoading) { widgets = [ diff --git a/lib/ui/components/pre_styled_buttons/pre_styled_buttons.dart b/lib/ui/components/pre_styled_buttons/pre_styled_buttons.dart index e9b7cc85..b3be1ee8 100644 --- a/lib/ui/components/pre_styled_buttons/pre_styled_buttons.dart +++ b/lib/ui/components/pre_styled_buttons/pre_styled_buttons.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ionicons/ionicons.dart'; import 'package:selfprivacy/config/brand_colors.dart'; diff --git a/lib/ui/pages/backup_details/backup_details.dart b/lib/ui/pages/backup_details/backup_details.dart new file mode 100644 index 00000000..97c263ad --- /dev/null +++ b/lib/ui/pages/backup_details/backup_details.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:selfprivacy/config/brand_colors.dart'; +import 'package:selfprivacy/config/brand_theme.dart'; +import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/cubit/app_config/app_config_cubit.dart'; +import 'package:selfprivacy/logic/cubit/backups/backups_cubit.dart'; +import 'package:selfprivacy/logic/models/backup.dart'; +import 'package:selfprivacy/logic/models/state_types.dart'; +import 'package:selfprivacy/ui/components/action_button/action_button.dart'; +import 'package:selfprivacy/ui/components/brand_alert/brand_alert.dart'; +import 'package:selfprivacy/ui/components/brand_button/brand_button.dart'; +import 'package:selfprivacy/ui/components/brand_divider/brand_divider.dart'; +import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart'; +import 'package:selfprivacy/ui/components/brand_text/brand_text.dart'; +import 'package:selfprivacy/ui/components/icon_status_mask/icon_status_mask.dart'; +import 'package:easy_localization/easy_localization.dart'; + +part 'header.dart'; + +var navigatorKey = GlobalKey(); + +class BackupDetails extends StatefulWidget { + const BackupDetails({Key? key}) : super(key: key); + + @override + _BackupDetailsState createState() => _BackupDetailsState(); +} + +class _BackupDetailsState extends State + with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + var isReady = context.watch().state is AppConfigFinished; + var isBackupInitialized = context.watch().state.isInitialized; + var backupStatus = context.watch().state.status; + var providerState = isReady && isBackupInitialized + ? (backupStatus == BackupStatusEnum.error + ? StateType.warning + : StateType.stable) + : StateType.uninitialized; + var preventActions = context.watch().state.preventActions; + var backupProgress = context.watch().state.progress; + var backupError = context.watch().state.error; + var backups = context.watch().state.backups; + var refreshing = context.watch().state.refreshing; + + return Scaffold( + appBar: PreferredSize( + child: Column( + children: [ + Container( + height: 51, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 15), + child: BrandText.h4('basis.details'.tr()), + ), + BrandDivider(), + ], + ), + preferredSize: Size.fromHeight(52), + ), + body: SingleChildScrollView( + physics: ClampingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: paddingH15V0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header( + providerState: providerState, + refreshing: refreshing, + ), + SizedBox(height: 10), + BrandText.h2('providers.backup.card_title'.tr()), + SizedBox(height: 10), + BrandText.body1('providers.backup.bottom_sheet.1'.tr()), + SizedBox(height: 20), + if (isReady && !isBackupInitialized) + BrandButton.rised( + onPressed: preventActions + ? null + : () async { + await context.read().createBucket(); + }, + text: 'providers.backup.initialize'.tr(), + ), + if (backupStatus == BackupStatusEnum.initializing) + BrandText.body1('providers.backup.waitingForRebuild'.tr()), + if (backupStatus != BackupStatusEnum.initializing && + backupStatus != BackupStatusEnum.noKey) + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Colors.grey.withOpacity(0.2), + width: 1, + ), + ), + elevation: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (backupStatus == BackupStatusEnum.initialized) + ListTile( + leading: Icon( + Icons.add_circle_outline_rounded, + color: BrandColors.textColor1, + ), + title: BrandText.h5( + 'providers.backup.create_new'.tr()), + ), + if (backupStatus == BackupStatusEnum.backingUp) + ListTile( + title: BrandText.h5('providers.backup.creating' + .tr(args: [ + (backupProgress * 100).round().toString() + ])), + subtitle: LinearProgressIndicator( + value: backupProgress, + backgroundColor: Colors.grey.withOpacity(0.2), + ), + ), + if (backupStatus == BackupStatusEnum.restoring) + ListTile( + title: BrandText.h5('providers.backup.restoring' + .tr(args: [ + (backupProgress * 100).round().toString() + ])), + subtitle: LinearProgressIndicator( + backgroundColor: Colors.grey.withOpacity(0.2), + ), + ), + if (backupStatus == BackupStatusEnum.error) + ListTile( + leading: Icon( + Icons.error_outline, + color: BrandColors.red1, + ), + title: BrandText.h5( + 'providers.backup.error_pending'.tr()), + ), + ], + ), + ), + SizedBox(height: 16), + // Card with a list of existing backups + // Each list item has a date + // When clicked, starts the restore action + if (backupStatus != BackupStatusEnum.initializing && + backupStatus != BackupStatusEnum.noKey) + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Colors.grey.withOpacity(0.2), + width: 1, + ), + ), + elevation: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: Icon( + Icons.refresh, + color: BrandColors.textColor1, + ), + title: + BrandText.h5('providers.backup.restore'.tr()), + ), + Divider( + height: 1.0, + ), + if (backups.isEmpty) + ListTile( + leading: Icon( + Icons.error_outline, + ), + title: Text('providers.backup.no_backups'.tr()), + ), + if (backups.isNotEmpty) + Column( + children: backups.map((backup) { + return ListTile( + onTap: preventActions + ? null + : () { + var nav = getIt(); + nav.showPopUpDialog(BrandAlert( + title: 'providers.backup.restoring' + .tr(), + contentText: + 'providers.backup.restore_alert' + .tr(args: [ + backup.time.toString() + ]), + actions: [ + ActionButton( + text: 'basis.cancel'.tr(), + ), + ActionButton( + onPressed: () => { + context + .read() + .restoreBackup(backup.id) + }, + text: 'modals.yes'.tr(), + ) + ], + )); + }, + title: Text( + MaterialLocalizations.of(context) + .formatShortDate(backup.time) + + ' ' + + TimeOfDay.fromDateTime(backup.time) + .format(context), + ), + ); + }).toList(), + ), + ], + ), + ), + if (backupStatus == BackupStatusEnum.error) + BrandText.body1(backupError.toString()), + ], + ), + ), + SizedBox(height: 10), + ], + ), + ), + ); + } +} diff --git a/lib/ui/pages/backup_details/header.dart b/lib/ui/pages/backup_details/header.dart new file mode 100644 index 00000000..f6821380 --- /dev/null +++ b/lib/ui/pages/backup_details/header.dart @@ -0,0 +1,70 @@ +part of 'backup_details.dart'; + +class _Header extends StatelessWidget { + const _Header( + {Key? key, required this.providerState, required this.refreshing}) + : super(key: key); + + final StateType providerState; + final bool refreshing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconStatusMask( + status: providerState, + child: Icon( + BrandIcons.save, + size: 40, + color: Colors.white, + ), + ), + Spacer(), + Padding( + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: 2, + ), + child: IconButton( + onPressed: refreshing + ? null + : () => {context.read().updateBackups()}, + icon: const Icon(Icons.refresh_rounded), + ), + ), + Padding( + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: 2, + ), + child: PopupMenuButton<_PopupMenuItemType>( + enabled: providerState != StateType.uninitialized, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + onSelected: (_PopupMenuItemType result) { + switch (result) { + case _PopupMenuItemType.reuploadKey: + context.read().reuploadKey(); + break; + } + }, + icon: Icon(Icons.more_vert), + itemBuilder: (BuildContext context) => [ + PopupMenuItem<_PopupMenuItemType>( + value: _PopupMenuItemType.reuploadKey, + child: Container( + padding: EdgeInsets.only(left: 5), + child: Text('providers.backup.reuploadKey'.tr()), + ), + ), + ], + ), + ), + ], + ); + } +} + +enum _PopupMenuItemType { reuploadKey } diff --git a/lib/ui/pages/more/app_settings/app_setting.dart b/lib/ui/pages/more/app_settings/app_setting.dart index 96cb83ce..895294c7 100644 --- a/lib/ui/pages/more/app_settings/app_setting.dart +++ b/lib/ui/pages/more/app_settings/app_setting.dart @@ -97,7 +97,7 @@ class _AppSettingsPageState extends State { return BrandAlert( title: 'modals.3'.tr(), contentText: 'modals.4'.tr(), - acitons: [ + actions: [ ActionButton( text: 'modals.5'.tr(), isRed: true, @@ -167,7 +167,7 @@ class _AppSettingsPageState extends State { return BrandAlert( title: 'modals.3'.tr(), contentText: 'modals.6'.tr(), - acitons: [ + actions: [ ActionButton( text: 'modals.7'.tr(), isRed: true, diff --git a/lib/ui/pages/more/more.dart b/lib/ui/pages/more/more.dart index e01fba11..3c06ef90 100644 --- a/lib/ui/pages/more/more.dart +++ b/lib/ui/pages/more/more.dart @@ -105,7 +105,7 @@ class MorePage extends StatelessWidget { title: 'modals.3'.tr(), contentText: 'more.delete_ssh_text'.tr(), - acitons: [ + actions: [ ActionButton( text: 'more.yes_delete'.tr(), isRed: true, diff --git a/lib/ui/pages/providers/providers.dart b/lib/ui/pages/providers/providers.dart index 5e22b66c..3d04290a 100644 --- a/lib/ui/pages/providers/providers.dart +++ b/lib/ui/pages/providers/providers.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:selfprivacy/config/brand_theme.dart'; import 'package:selfprivacy/logic/cubit/app_config/app_config_cubit.dart'; +import 'package:selfprivacy/logic/cubit/backups/backups_cubit.dart'; import 'package:selfprivacy/logic/cubit/jobs/jobs_cubit.dart'; import 'package:selfprivacy/logic/cubit/providers/providers_cubit.dart'; import 'package:selfprivacy/logic/models/provider.dart'; @@ -12,6 +13,7 @@ import 'package:selfprivacy/ui/components/brand_text/brand_text.dart'; import 'package:selfprivacy/ui/components/icon_status_mask/icon_status_mask.dart'; import 'package:selfprivacy/ui/components/not_ready_card/not_ready_card.dart'; import 'package:selfprivacy/ui/helpers/modals.dart'; +import 'package:selfprivacy/ui/pages/backup_details/backup_details.dart'; import 'package:selfprivacy/ui/pages/server_details/server_details.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:selfprivacy/utils/ui_helpers.dart'; @@ -29,6 +31,7 @@ class _ProvidersPageState extends State { @override Widget build(BuildContext context) { var isReady = context.watch().state is AppConfigFinished; + var isBackupInitialized = context.watch().state.isInitialized; final cards = ProviderType.values .map( @@ -36,7 +39,11 @@ class _ProvidersPageState extends State { padding: EdgeInsets.only(bottom: 30), child: _Card( provider: ProviderModel( - state: isReady ? StateType.stable : StateType.uninitialized, + state: isReady + ? (type == ProviderType.backup && !isBackupInitialized + ? StateType.uninitialized + : StateType.stable) + : StateType.uninitialized, type: type, ), ), @@ -113,14 +120,12 @@ class _Card extends StatelessWidget { title = 'providers.backup.card_title'.tr(); stableText = 'providers.backup.status'.tr(); - onTap = () => showModalBottomSheet( + onTap = () => showBrandBottomSheet( context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, builder: (BuildContext context) { - return _ProviderDetails( - provider: provider, - statusText: stableText, + return BrandBottomSheet( + isExpended: true, + child: BackupDetails(), ); }, ); diff --git a/lib/utils/extensions/elevation_extension.dart b/lib/utils/extensions/elevation_extension.dart index d39be2ac..78de82f1 100644 --- a/lib/utils/extensions/elevation_extension.dart +++ b/lib/utils/extensions/elevation_extension.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; extension ElevationExtension on BoxDecoration { - BoxDecoration copyWith({ Color? color, DecorationImage? image, diff --git a/lib/utils/named_font_weight.dart b/lib/utils/named_font_weight.dart index 9cb2b128..e2f85bb9 100644 --- a/lib/utils/named_font_weight.dart +++ b/lib/utils/named_font_weight.dart @@ -7,4 +7,4 @@ class NamedFontWeight { static const FontWeight demiBold = FontWeight.w600; static const FontWeight bold = FontWeight.bold; static const FontWeight extraBold = FontWeight.w800; -} \ No newline at end of file +} diff --git a/lib/utils/password_generator.dart b/lib/utils/password_generator.dart index d184a8e3..35bdaecb 100644 --- a/lib/utils/password_generator.dart +++ b/lib/utils/password_generator.dart @@ -96,7 +96,7 @@ class StringGenerators { hasUppercaseLetters: true, hasNumbers: true, ); - + static StringGeneratorFunction apiToken = () => getRandomString( 64, hasLowercaseLetters: true, diff --git a/lib/utils/route_transitions/slide_right.dart b/lib/utils/route_transitions/slide_right.dart index ae9129e7..f01c4b0f 100644 --- a/lib/utils/route_transitions/slide_right.dart +++ b/lib/utils/route_transitions/slide_right.dart @@ -37,7 +37,8 @@ class SlideRightRoute extends PageRouteBuilder { SlideRightRoute(this.widget) : super( pageBuilder: pageBuilder(widget), - transitionsBuilder: transitionsBuilder as Widget Function(BuildContext, Animation, Animation, Widget), + transitionsBuilder: transitionsBuilder as Widget Function( + BuildContext, Animation, Animation, Widget), ); final Widget widget; diff --git a/lib/utils/ui_helpers.dart b/lib/utils/ui_helpers.dart index b0b43851..9ce41d0c 100644 --- a/lib/utils/ui_helpers.dart +++ b/lib/utils/ui_helpers.dart @@ -1,6 +1,6 @@ import 'package:selfprivacy/logic/cubit/app_config/app_config_cubit.dart'; -/// it's ui helpers use only for ui components, don't use for logic components. +/// it's ui helpers use only for ui components, don't use for logic components. class UiHelpers { static String getDomainName(AppConfigState config) => config.isDomainFilled From d21b9df73497323ec253862ab59c67b86d25a434 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Mon, 6 Dec 2021 18:33:17 +0000 Subject: [PATCH 3/6] Version bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index fe577006..5d8d9111 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: selfprivacy description: selfprivacy.org publish_to: 'none' -version: 0.3.0+7 +version: 0.4.0+8 environment: sdk: '>=2.13.4 <3.0.0' From 35c1eea7f097ef1b72b3fd2cc81b97264db3d92d Mon Sep 17 00:00:00 2001 From: Inex Code Date: Thu, 9 Dec 2021 03:22:33 +0000 Subject: [PATCH 4/6] Fix error when error is null --- lib/logic/cubit/backups/backups_cubit.dart | 4 ++-- lib/logic/models/backup.dart | 2 +- lib/logic/models/backup.g.dart | 2 +- lib/ui/pages/backup_details/backup_details.dart | 7 +++++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/logic/cubit/backups/backups_cubit.dart b/lib/logic/cubit/backups/backups_cubit.dart index 71d6ce15..574808bf 100644 --- a/lib/logic/cubit/backups/backups_cubit.dart +++ b/lib/logic/cubit/backups/backups_cubit.dart @@ -56,7 +56,7 @@ class BackupsCubit extends AppConfigDependendCubit { preventActions: false, progress: status.progress, status: status.status, - error: status.errorMessage, + error: status.errorMessage ?? '', refreshing: false, )); break; @@ -69,7 +69,7 @@ class BackupsCubit extends AppConfigDependendCubit { preventActions: true, progress: status.progress, status: status.status, - error: status.errorMessage, + error: status.errorMessage ?? '', refreshTimer: Duration(seconds: 5), refreshing: false, )); diff --git a/lib/logic/models/backup.dart b/lib/logic/models/backup.dart index 7edff192..95737897 100644 --- a/lib/logic/models/backup.dart +++ b/lib/logic/models/backup.dart @@ -41,7 +41,7 @@ class BackupStatus { final BackupStatusEnum status; final double progress; @JsonKey(name: 'error_message') - final String errorMessage; + final String? errorMessage; factory BackupStatus.fromJson(Map json) => _$BackupStatusFromJson(json); diff --git a/lib/logic/models/backup.g.dart b/lib/logic/models/backup.g.dart index ddeff755..c1b50d03 100644 --- a/lib/logic/models/backup.g.dart +++ b/lib/logic/models/backup.g.dart @@ -17,7 +17,7 @@ BackupStatus _$BackupStatusFromJson(Map json) { return BackupStatus( status: _$enumDecode(_$BackupStatusEnumEnumMap, json['status']), progress: (json['progress'] as num).toDouble(), - errorMessage: json['error_message'] as String, + errorMessage: json['error_message'] as String?, ); } diff --git a/lib/ui/pages/backup_details/backup_details.dart b/lib/ui/pages/backup_details/backup_details.dart index 97c263ad..752be742 100644 --- a/lib/ui/pages/backup_details/backup_details.dart +++ b/lib/ui/pages/backup_details/backup_details.dart @@ -105,6 +105,13 @@ class _BackupDetailsState extends State children: [ if (backupStatus == BackupStatusEnum.initialized) ListTile( + onTap: preventActions + ? null + : () async { + await context + .read() + .createBackup(); + }, leading: Icon( Icons.add_circle_outline_rounded, color: BrandColors.textColor1, From 2b8c009ef18e757338e087cb614175634aefccc0 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Thu, 9 Dec 2021 03:35:15 +0000 Subject: [PATCH 5/6] Add button to force refetch backups list --- assets/translations/en.json | 4 +++- assets/translations/ru.json | 5 ++++- lib/logic/cubit/backups/backups_cubit.dart | 7 +++++++ lib/ui/pages/backup_details/header.dart | 12 +++++++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 84a07fd4..76ee8bb9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -110,7 +110,9 @@ "creating": "Creating a new backup: {}%", "restoring": "Restoring from backup", "error_pending": "Server returned error, check it below", - "restore_alert": "You are about to restore from backup created on {}. All current data will be lost. Are you sure?" + "restore_alert": "You are about to restore from backup created on {}. All current data will be lost. Are you sure?", + "refetchBackups": "Refetch backup list", + "refetchingList": "In a few minutes list will be updated" } }, "not_ready_card": { diff --git a/assets/translations/ru.json b/assets/translations/ru.json index e36e8d2f..192d3164 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -110,7 +110,10 @@ "creating": "Создание копии: {}%", "restoring": "Восстановление из копии", "error_pending": "Сервер вернул ошибку: проверьте её ниже.", - "restore_alert": "Вы собираетесь восстановить из копии созданной {}. Все текущие данные будут потеряны. Вы уверены?" + "restore_alert": "Вы собираетесь восстановить из копии созданной {}. Все текущие данные будут потеряны. Вы уверены?", + "refetchBackups": "Обновить список копий", + "refetchingList": "Через несколько минут список будет обновлён" + } }, "not_ready_card": { diff --git a/lib/logic/cubit/backups/backups_cubit.dart b/lib/logic/cubit/backups/backups_cubit.dart index 574808bf..7fb42ed3 100644 --- a/lib/logic/cubit/backups/backups_cubit.dart +++ b/lib/logic/cubit/backups/backups_cubit.dart @@ -148,6 +148,13 @@ class BackupsCubit extends AppConfigDependendCubit { Timer(state.refreshTimer, () => updateBackups(useTimer: true)); } + Future forceUpdateBackups() async { + emit(state.copyWith(preventActions: true)); + await api.forceBackupListReload(); + getIt().showSnackBar('providers.backup.refetchingList'); + emit(state.copyWith(preventActions: false)); + } + Future createBackup() async { emit(state.copyWith(preventActions: true)); await api.startBackup(); diff --git a/lib/ui/pages/backup_details/header.dart b/lib/ui/pages/backup_details/header.dart index f6821380..0dd93b53 100644 --- a/lib/ui/pages/backup_details/header.dart +++ b/lib/ui/pages/backup_details/header.dart @@ -48,6 +48,9 @@ class _Header extends StatelessWidget { case _PopupMenuItemType.reuploadKey: context.read().reuploadKey(); break; + case _PopupMenuItemType.refetchBackups: + context.read().forceUpdateBackups(); + break; } }, icon: Icon(Icons.more_vert), @@ -59,6 +62,13 @@ class _Header extends StatelessWidget { child: Text('providers.backup.reuploadKey'.tr()), ), ), + PopupMenuItem<_PopupMenuItemType>( + value: _PopupMenuItemType.refetchBackups, + child: Container( + padding: EdgeInsets.only(left: 5), + child: Text('providers.backup.refetchBackups'.tr()), + ), + ), ], ), ), @@ -67,4 +77,4 @@ class _Header extends StatelessWidget { } } -enum _PopupMenuItemType { reuploadKey } +enum _PopupMenuItemType { reuploadKey, refetchBackups } From f633fecd57d355a9f9d3a71465d71dd60e4acbf4 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Thu, 9 Dec 2021 03:44:05 +0000 Subject: [PATCH 6/6] Fix backup list parsing --- lib/logic/api_maps/server.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logic/api_maps/server.dart b/lib/logic/api_maps/server.dart index 7ec2e5c7..7705dcc4 100644 --- a/lib/logic/api_maps/server.dart +++ b/lib/logic/api_maps/server.dart @@ -153,7 +153,7 @@ class ServerApi extends ApiMap { response = await client.get( '/services/restic/backup/list', ); - return response.data.map((e) => Backup.fromJson(e)).toList(); + return response.data.map((e) => Backup.fromJson(e)).toList(); } catch (e) { print(e); }