mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-07-17 23:54:08 +00:00
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
384 lines
13 KiB
Dart
384 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:clock/clock.dart' as clock;
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:mutex/mutex.dart';
|
|
import 'package:twonly/core/bridge/wrapper/backup.dart';
|
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/locator.dart';
|
|
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
|
import 'package:twonly/src/model/json/backup.model.dart';
|
|
import 'package:twonly/src/services/api/utils.api.dart';
|
|
import 'package:twonly/src/services/user.service.dart';
|
|
import 'package:twonly/src/utils/keyvalue.dart';
|
|
import 'package:twonly/src/utils/log.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/utils/storage.dart';
|
|
|
|
class BackupService {
|
|
static final Mutex _protected = Mutex();
|
|
|
|
static String _getIdentityBackupUrl(String backupId) =>
|
|
'${apiService.apiEndpoint}/backup/identity/$backupId';
|
|
|
|
static String _getArchiveBackupUrl(String backupDownloadToken, int? userId) =>
|
|
'${apiService.apiEndpoint}/backup/archive/${userId == null ? '' : '${userId.toRadixString(16).padLeft(16, '0').toUpperCase()}/'}$backupDownloadToken';
|
|
|
|
static final _backupUpdateController = StreamController<void>.broadcast();
|
|
static Stream<void> get onBackupUpdated => _backupUpdateController.stream;
|
|
|
|
static Future<CurrentBackupStatus> getData() async {
|
|
return CurrentBackupStatus.fromJson(
|
|
(await KeyValueStore.get(KeyValueKeys.currentBackupState)) ??
|
|
CurrentBackupStatus().toJson(),
|
|
);
|
|
}
|
|
|
|
static Future<void> updateBackupPassword(String password) async {
|
|
// Set or reset the backup data...
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.currentBackupState,
|
|
CurrentBackupStatus().toJson(),
|
|
);
|
|
_backupUpdateController.add(null);
|
|
|
|
await RustBackupIdentity.setBackupPasswordKeys(
|
|
password: password,
|
|
// Using the userId is this will never change in a users lifecycle
|
|
userId: userService.currentUser.userId,
|
|
);
|
|
|
|
await UserService.update((u) => u.isBackupEnabled = true);
|
|
|
|
unawaited(makeBackup(force: true));
|
|
}
|
|
|
|
static Future<void> handleBackupStatusUpdate(
|
|
String taskId,
|
|
TaskStatusUpdate update,
|
|
) async {
|
|
var status = LastBackupUploadState.success;
|
|
|
|
if (update.status == TaskStatus.failed ||
|
|
update.status == TaskStatus.canceled) {
|
|
status = LastBackupUploadState.failed;
|
|
} else if (update.status != TaskStatus.complete) {
|
|
Log.info('Backup is in state: ${update.status}');
|
|
return;
|
|
}
|
|
await _protected.protect(() async {
|
|
final backup = await getData();
|
|
if (taskId == 'backup_identity') {
|
|
backup
|
|
..identityLastSuccessFull = clock.clock.now()
|
|
..identityState = status;
|
|
} else {
|
|
backup
|
|
..archiveLastSuccessFull = clock.clock.now()
|
|
..archiveState = status;
|
|
}
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.currentBackupState,
|
|
backup.toJson(),
|
|
);
|
|
_backupUpdateController.add(null);
|
|
});
|
|
}
|
|
|
|
static Future<void> makeBackup({bool force = false}) async {
|
|
await _protected.protect(() async {
|
|
final backup = await getData();
|
|
|
|
final lastDay = clock.clock.now().subtract(const Duration(days: 1));
|
|
final lastWeek = clock.clock.now().subtract(const Duration(days: 7));
|
|
|
|
if (force ||
|
|
backup.identityLastSuccessFull == null ||
|
|
(backup.identityState != LastBackupUploadState.pending &&
|
|
backup.identityLastSuccessFull!.isBefore(lastWeek) ||
|
|
backup.identityLastSuccessFull!.isBefore(
|
|
lastWeek.subtract(const Duration(days: 1)),
|
|
))) {
|
|
final backupId = await RustBackupIdentity.getBackupId();
|
|
if (backupId == null) {
|
|
Log.warn('No backup password was set by the user.');
|
|
backup.identityState = LastBackupUploadState.failed;
|
|
await UserService.update((u) => u.isBackupEnabled = false);
|
|
} else {
|
|
Log.info('Performing a identity backup.');
|
|
final encryptedBackup =
|
|
await RustBackupIdentity.getIdentityBackupBytes();
|
|
|
|
final backupTempFile = File(
|
|
'${AppEnvironment.cacheDir}/identity_backup.bin',
|
|
)..writeAsBytesSync(encryptedBackup);
|
|
|
|
Log.info(
|
|
'Identity backup has a size of ${backupTempFile.statSync().size}.',
|
|
);
|
|
|
|
final task = UploadTask.fromFile(
|
|
taskId: 'backup_identity',
|
|
httpRequestMethod: 'PUT',
|
|
file: backupTempFile,
|
|
url: _getIdentityBackupUrl(backupId),
|
|
post: 'binary',
|
|
retries: 2,
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
);
|
|
if (await FileDownloader().enqueue(task)) {
|
|
Log.info('Starting upload from backup identity.');
|
|
backup
|
|
..identityState = LastBackupUploadState.pending
|
|
..identityLastSuccessFull = clock.clock.now()
|
|
..identitySize = encryptedBackup.length;
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.currentBackupState,
|
|
backup.toJson(),
|
|
);
|
|
_backupUpdateController.add(null);
|
|
} else {
|
|
Log.error('Error starting upload task for backup identity.');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (force ||
|
|
backup.archiveLastSuccessFull == null ||
|
|
(backup.archiveState != LastBackupUploadState.pending &&
|
|
backup.archiveLastSuccessFull!.isBefore(lastDay) ||
|
|
backup.archiveLastSuccessFull!.isBefore(
|
|
lastDay.subtract(const Duration(days: 1)),
|
|
))) {
|
|
Log.info('Creating a archive backup.');
|
|
late final String backupArchive;
|
|
late final String backupDownloadToken;
|
|
try {
|
|
(backupDownloadToken, backupArchive) =
|
|
await RustBackupArchive.createBackupArchive();
|
|
} catch (e) {
|
|
Log.warn('Creating archive backup failed: $e');
|
|
return;
|
|
}
|
|
Log.info(
|
|
'Archive backup has a size of ${File(backupArchive).statSync().size}.',
|
|
);
|
|
|
|
final headers = await getAuthenticationHeader();
|
|
if (headers == null) {
|
|
Log.error('Auth headers are empty. Returning');
|
|
return;
|
|
}
|
|
|
|
final task = UploadTask.fromFile(
|
|
taskId: 'backup_archive',
|
|
file: File(backupArchive),
|
|
url: _getArchiveBackupUrl(backupDownloadToken, null),
|
|
priority: 0,
|
|
retries: 10,
|
|
headers: headers,
|
|
);
|
|
if (await FileDownloader().enqueue(task)) {
|
|
Log.info('Uploading backup archive.');
|
|
backup
|
|
..archiveState = LastBackupUploadState.pending
|
|
..archiveLastSuccessFull = clock.clock.now()
|
|
..archiveSize = File(backupArchive).statSync().size;
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.currentBackupState,
|
|
backup.toJson(),
|
|
);
|
|
_backupUpdateController.add(null);
|
|
} else {
|
|
Log.error('Error starting upload task for backup archive.');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<BackupRecovery?> getBackupRecoveryData() async {
|
|
final stateJson = await KeyValueStore.get(KeyValueKeys.backupRecoveryState);
|
|
if (stateJson == null) return null;
|
|
return BackupRecovery.fromJson(stateJson);
|
|
}
|
|
|
|
static Future<RecoveryError?> _nextBackupStage() async {
|
|
return _protected.protect(() async {
|
|
final recoveryData = await getBackupRecoveryData();
|
|
if (recoveryData == null) return null;
|
|
|
|
if (recoveryData.state == BackupRecoveryState.identityBackupStarted) {
|
|
// First start to download the identity to restore the KeyManager
|
|
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
|
|
userId: recoveryData.userId,
|
|
password: recoveryData.password,
|
|
);
|
|
final backupId = uint8ListToHex(backupKeys.backupId);
|
|
final backupServerUrl = _getIdentityBackupUrl(backupId);
|
|
final (encryptedBytes, error) = await _downloadBackup(backupServerUrl);
|
|
if (error != null || encryptedBytes == null) {
|
|
Log.error(error);
|
|
return error;
|
|
}
|
|
|
|
Log.info('Restored identity.');
|
|
|
|
try {
|
|
await RustBackupIdentity.restoreIdentityBackup(
|
|
keys: backupKeys,
|
|
encryptedBytes: encryptedBytes,
|
|
);
|
|
recoveryData.state = BackupRecoveryState.archiveBackupStarted;
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.backupRecoveryState,
|
|
recoveryData.toJson(),
|
|
);
|
|
_backupUpdateController.add(null);
|
|
} catch (e) {
|
|
Log.error(e);
|
|
return RecoveryError.unkownError;
|
|
}
|
|
}
|
|
|
|
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
|
// The KeyManager was restored sucessfully, restore the archive now.
|
|
try {
|
|
final downloadToken =
|
|
await RustBackupArchive.getBackupDownloadToken();
|
|
if (downloadToken == null) {
|
|
// identity was not restored correctly try this again.
|
|
recoveryData.state = BackupRecoveryState.identityBackupStarted;
|
|
await KeyValueStore.put(
|
|
KeyValueKeys.backupRecoveryState,
|
|
recoveryData.toJson(),
|
|
);
|
|
return RecoveryError.tryAgainLater;
|
|
}
|
|
|
|
final backupServerUrl = _getArchiveBackupUrl(
|
|
downloadToken,
|
|
recoveryData.userId,
|
|
);
|
|
final backupArchive = await _downloadBackup(backupServerUrl);
|
|
if (backupArchive.$2 != null || backupArchive.$1 == null) {
|
|
return backupArchive.$2;
|
|
}
|
|
|
|
final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin')
|
|
..writeAsBytesSync(backupArchive.$1!);
|
|
|
|
await RustBackupArchive.restoreBackupArchive(
|
|
filePath: archiveFile.path,
|
|
);
|
|
await UserService.update((u) {
|
|
u.deviceId += 1;
|
|
});
|
|
await KeyValueStore.delete(
|
|
KeyValueKeys.backupRecoveryState,
|
|
);
|
|
} catch (e) {
|
|
Log.error(e);
|
|
return RecoveryError.unkownError;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
|
|
static Future<RecoveryError?> tryToReinstallTheArchive() async {
|
|
final userId = await RustKeyManager.getUserId();
|
|
if (userId == null) return null;
|
|
|
|
final state = BackupRecovery(
|
|
username: '',
|
|
userId: userId,
|
|
password: '',
|
|
)..state = BackupRecoveryState.archiveBackupStarted;
|
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
|
return _nextBackupStage();
|
|
}
|
|
|
|
static Future<RecoveryError?> startFullBackupRecovery(
|
|
String username,
|
|
String password,
|
|
) async {
|
|
final userId = await apiService.getUserIdFromUsername(username);
|
|
if (userId == null) {
|
|
return RecoveryError.usernameNotValid;
|
|
}
|
|
|
|
final state = BackupRecovery(
|
|
username: username,
|
|
userId: userId,
|
|
password: password,
|
|
);
|
|
|
|
await deleteLocalUserData();
|
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
|
return _nextBackupStage();
|
|
}
|
|
|
|
static Future<RecoveryError?> startPasswordlessBackupRecovery(
|
|
int userId,
|
|
String username,
|
|
Uint8List keyManagerBytes,
|
|
) async {
|
|
final state = BackupRecovery(
|
|
username: username,
|
|
password: '',
|
|
userId: userId,
|
|
)..state = BackupRecoveryState.archiveBackupStarted;
|
|
|
|
await deleteLocalUserData();
|
|
|
|
// Import KeyManager keys into secure storage & in-memory key manager
|
|
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
|
|
|
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
|
return _nextBackupStage();
|
|
}
|
|
|
|
static Future<(Uint8List?, RecoveryError?)> _downloadBackup(
|
|
String backupServerUrl,
|
|
) async {
|
|
late http.Response response;
|
|
|
|
try {
|
|
response = await http.get(
|
|
Uri.parse(backupServerUrl),
|
|
headers: {
|
|
HttpHeaders.acceptHeader: 'application/octet-stream',
|
|
},
|
|
);
|
|
} catch (e) {
|
|
Log.warn('Error fetching backup: $e');
|
|
return (null, RecoveryError.noInternet);
|
|
}
|
|
|
|
Log.info('Backup downlaod status: ${response.statusCode}');
|
|
|
|
switch (response.statusCode) {
|
|
case 200:
|
|
return (response.bodyBytes, null);
|
|
case 404:
|
|
return (null, RecoveryError.passwordInvalid);
|
|
default:
|
|
return (null, RecoveryError.tryAgainLater);
|
|
}
|
|
}
|
|
}
|
|
|
|
enum RecoveryError {
|
|
usernameNotValid,
|
|
passwordInvalid,
|
|
tryAgainLater,
|
|
noInternet,
|
|
unkownError,
|
|
}
|