diff --git a/lib/common/models/sync/sync.dart b/lib/common/models/sync/sync.dart new file mode 100644 index 0000000..8b7122d --- /dev/null +++ b/lib/common/models/sync/sync.dart @@ -0,0 +1,11 @@ +import 'package:moodiary/common/values/sync_status.dart'; + +class SyncResult { + final SyncStatus status; + T? data; + + SyncResult({ + required this.status, + this.data, + }); +} diff --git a/lib/common/values/sync_status.dart b/lib/common/values/sync_status.dart new file mode 100644 index 0000000..088e99e --- /dev/null +++ b/lib/common/values/sync_status.dart @@ -0,0 +1,32 @@ +/// 同步状态枚举 +enum SyncStatus { + /// 已分配任务但还未执行 + pending, + + /// 正在同步中 + syncing, + + /// 同步成功 + success, + + /// 同步失败 + failure, + + /// 验证失败 + invalid, + + /// 未知状态 + unknown, +} + +/// 连接状态枚举 +enum ConnectivityStatus { + /// 未连接 + disconnected, + + /// 正在连接 + connecting, + + /// 已连接 + connected, +} diff --git a/lib/components/web_dav/web_dav_logic.dart b/lib/components/web_dav/web_dav_logic.dart index 5bb7094..1802573 100644 --- a/lib/components/web_dav/web_dav_logic.dart +++ b/lib/components/web_dav/web_dav_logic.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:moodiary/common/values/webdav.dart'; import 'package:moodiary/presentation/pref.dart'; +import 'package:moodiary/presentation/secure_storage.dart'; import 'package:moodiary/utils/notice_util.dart'; import 'package:moodiary/utils/webdav_util.dart'; import 'package:refreshed/refreshed.dart'; @@ -27,6 +28,7 @@ class WebDavLogic extends GetxController { if (state.hasOption.value) { await checkConnectivity(); } + await checkHasUserKey(); super.onReady(); } @@ -41,6 +43,11 @@ class WebDavLogic extends GetxController { super.onClose(); } + Future checkHasUserKey() async { + state.hasUserKey.value = + (await SecureStorageUtil.getValue('userKey')) != null; + } + Future checkConnectivity() async { state.connectivityStatus.value = WebDavConnectivityStatus.connecting; final res = await webDav.checkConnectivity(); @@ -111,4 +118,9 @@ class WebDavLogic extends GetxController { await PrefUtil.setValue('autoSyncAfterChange', value); state.autoSyncAfterChange.value = value; } + + void setSyncEncryption(bool value) async { + await PrefUtil.setValue('syncEncryption', value); + state.syncEncryption.value = value; + } } diff --git a/lib/components/web_dav/web_dav_state.dart b/lib/components/web_dav/web_dav_state.dart index 07a5456..6322fb0 100644 --- a/lib/components/web_dav/web_dav_state.dart +++ b/lib/components/web_dav/web_dav_state.dart @@ -18,5 +18,8 @@ class WebDavState { RxBool autoSyncAfterChange = PrefUtil.getValue('autoSyncAfterChange')!.obs; + RxBool syncEncryption = PrefUtil.getValue('syncEncryption')!.obs; + RxBool hasUserKey = false.obs; + WebDavState(); } diff --git a/lib/components/web_dav/web_dav_view.dart b/lib/components/web_dav/web_dav_view.dart index 025e66f..80922db 100644 --- a/lib/components/web_dav/web_dav_view.dart +++ b/lib/components/web_dav/web_dav_view.dart @@ -59,6 +59,14 @@ class WebDavComponent extends StatelessWidget { subtitle: l10n.webdavSyncAfterChangeDes, ); }), + Obx(() { + return AdaptiveSwitchListTile( + value: state.syncEncryption.value, + onChanged: state.hasUserKey.value ? logic.setSyncEncryption : null, + title: Text(l10n.webdavSyncEncryption), + subtitle: l10n.webdavSyncEncryptionDes, + ); + }), Padding( padding: const EdgeInsets.all(16.0), child: Form( diff --git a/lib/services/sync/impl/minio_impl.dart b/lib/services/sync/impl/minio_impl.dart new file mode 100644 index 0000000..fa3596a --- /dev/null +++ b/lib/services/sync/impl/minio_impl.dart @@ -0,0 +1,108 @@ +// import 'dart:ui'; +// +// import 'package:minio/minio.dart'; +// import 'package:moodiary/common/models/isar/diary.dart'; +// import 'package:moodiary/common/models/sync/sync.dart'; +// import 'package:moodiary/services/sync/sync.dart'; +// +// class MinioSyncServiceImpl implements SyncService { +// final String endPoint; +// +// final String accessKey; +// +// final String secretKey; +// +// final String bucketName; +// +// late final Minio _client; +// +// MinioSyncServiceImpl({ +// required this.endPoint, +// required this.accessKey, +// required this.secretKey, +// required this.bucketName, +// }); +// +// @override +// Future init() async { +// _client = Minio( +// endPoint: endPoint, +// accessKey: accessKey, +// secretKey: secretKey, +// ); +// } +// +// @override +// Future checkConnectivity() { +// // TODO: implement checkConnectivity +// throw UnimplementedError(); +// } +// +// @override +// Future>> fetchServerSyncData() { +// // TODO: implement fetchServerSyncData +// throw UnimplementedError(); +// } +// +// @override +// Future updateServerSyncData(Map syncData) { +// // TODO: implement updateServerSyncData +// throw UnimplementedError(); +// } +// +// @override +// Future deleteDiary({ +// required Diary diary, +// VoidCallback? onStart, +// VoidCallback? onComplete, +// Function(int p1, int p2)? onProgress, +// }) { +// // TODO: implement deleteDiary +// throw UnimplementedError(); +// } +// +// @override +// Future downloadDiary({ +// required String diaryId, +// VoidCallback? onStart, +// VoidCallback? onComplete, +// Function(int p1, int p2)? onProgress, +// }) { +// // TODO: implement downloadDiary +// throw UnimplementedError(); +// } +// +// @override +// Future updateDiary({ +// required Diary oldDiary, +// required Diary newDiary, +// VoidCallback? onStart, +// VoidCallback? onComplete, +// Function(int p1, int p2)? onProgress, +// }) { +// // TODO: implement updateDiary +// throw UnimplementedError(); +// } +// +// @override +// Future uploadDiary({ +// required Diary diary, +// VoidCallback? onStart, +// VoidCallback? onComplete, +// Function(int p1, int p2)? onProgress, +// }) { +// // TODO: implement uploadDiary +// throw UnimplementedError(); +// } +// +// @override +// Future syncDiary({ +// required List diaries, +// VoidCallback? onStart, +// VoidCallback? onComplete, +// Function(int p1, int p2)? onProgress, +// }) { +// // TODO: implement syncDiary +// throw UnimplementedError(); +// } +// } diff --git a/lib/services/sync/impl/webdav_impl.dart b/lib/services/sync/impl/webdav_impl.dart new file mode 100644 index 0000000..902fbf4 --- /dev/null +++ b/lib/services/sync/impl/webdav_impl.dart @@ -0,0 +1,266 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:moodiary/common/models/isar/diary.dart'; +import 'package:moodiary/common/models/sync/sync.dart'; +import 'package:moodiary/common/values/sync_status.dart'; +import 'package:moodiary/common/values/webdav.dart'; +import 'package:moodiary/services/sync/sync.dart'; +import 'package:moodiary/utils/log_util.dart'; +import 'package:refreshed/refreshed.dart'; +import 'package:webdav_client/webdav_client.dart' as webdav; + +class WebdavSyncServiceImpl implements SyncService { + final String url; + final String username; + final String password; + + late final webdav.Client _client; + final Rx _connectivity = + ConnectivityStatus.connecting.obs; + + bool get _isConnected => _connectivity.value == ConnectivityStatus.connected; + late Map _syncStatus; + Timer? _connectivityTimer; + + WebdavSyncServiceImpl({ + required this.url, + required this.username, + required this.password, + }); + + @override + Future init() async { + _client = webdav.newClient( + url, + user: username, + password: password, + debug: false, + ); + await checkConnectivity(); + startPolling(); + } + + void startPolling({Duration interval = const Duration(minutes: 1)}) { + _connectivityTimer?.cancel(); + _connectivityTimer = Timer.periodic(interval, (timer) async { + await checkConnectivity(); + }); + } + + void stopPolling() { + _connectivityTimer?.cancel(); + } + + @override + Future checkConnectivity() async { + _connectivity.value = ConnectivityStatus.connecting; + try { + await _client.ping().timeout(const Duration(seconds: 3)); + _connectivity.value = ConnectivityStatus.connected; + } catch (e) { + LogUtil.printError("WebDAV Error Check Connectivity", error: e); + _connectivity.value = ConnectivityStatus.disconnected; + } + } + + Future initDir() async { + if (!_isConnected) return; + + final paths = [ + WebDavOptions.imagePath, + WebDavOptions.videoPath, + WebDavOptions.audioPath, + WebDavOptions.diaryPath, + WebDavOptions.categoryPath, + ]; + + for (final path in paths) { + try { + await _client.mkdirAll(path); + } catch (e) { + LogUtil.printError("创建目录失败: $path", error: e); + } + } + + try { + await _client.read(WebDavOptions.syncFlagPath); + } catch (_) { + await _client.write( + WebDavOptions.syncFlagPath, + utf8.encode(jsonEncode({})), + ); + } + } + + @override + Future>> fetchServerSyncData() async { + if (!_isConnected) return SyncResult(status: SyncStatus.invalid); + + try { + final response = await _client.read(WebDavOptions.syncFlagPath); + if (response.isNotEmpty) { + return SyncResult( + status: SyncStatus.success, + data: jsonDecode(utf8.decode(response)) as Map, + ); + } + } catch (e) { + LogUtil.printError("获取服务器同步数据失败", error: e); + } + return SyncResult(status: SyncStatus.failure); + } + + @override + Future> updateServerSyncData( + Map syncData, + ) async { + if (!_isConnected) return SyncResult(status: SyncStatus.invalid); + + try { + await _client.write( + WebDavOptions.syncFlagPath, + utf8.encode(jsonEncode(syncData)), + ); + return SyncResult(status: SyncStatus.success, data: true); + } catch (e) { + LogUtil.printError("更新服务器同步数据失败", error: e); + return SyncResult(status: SyncStatus.failure); + } + } + + @override + Future deleteDiary({ + required Diary diary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int p1, int p2)? onProgress, + }) async { + if (!_isConnected) return SyncResult(status: SyncStatus.invalid); + + if (!_syncStatus.containsKey(diary.id)) { + return SyncResult(status: SyncStatus.success); + } + + _syncStatus[diary.id] = 'delete'; + await updateServerSyncData(_syncStatus); + + // 批量删除文件 + try { + await Future.wait([ + _deleteFile('${WebDavOptions.diaryPath}/${diary.id}.json'), + _deleteFile('${WebDavOptions.diaryPath}/${diary.id}.bin'), + _deleteFiles( + diary.imageName, + '${WebDavOptions.imagePath}/${diary.id}', + 'image', + ), + _deleteFiles( + diary.audioName, + '${WebDavOptions.audioPath}/${diary.id}', + 'audio', + ), + _deleteFiles( + diary.videoName, + '${WebDavOptions.videoPath}/${diary.id}', + 'video', + ), + _deleteFiles( + diary.videoName + .map( + (videoName) => 'thumbnail-${videoName.substring(6, 42)}.jpeg', + ) + .toList(), + '${WebDavOptions.videoPath}/${diary.id}', + 'thumbnail', + ), + _deleteFile('${WebDavOptions.imagePath}/${diary.id}'), + _deleteFile('${WebDavOptions.audioPath}/${diary.id}'), + _deleteFile('${WebDavOptions.videoPath}/${diary.id}'), + ]); + } catch (e) { + LogUtil.printError("删除日记失败", error: e); + return SyncResult(status: SyncStatus.failure); + } + + return SyncResult(status: SyncStatus.success); + } + + Future _deleteFile(String path) async { + try { + await _client.remove(path); + } catch (e) { + rethrow; + } + } + + Future _deleteFiles( + List fileNames, + String resourcePath, + String type, + ) async { + for (final fileName in fileNames) { + await _deleteFile('$resourcePath/$fileName'); + } + } + + @override + Future downloadDiary({ + required String diaryId, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int p1, int p2)? onProgress, + }) async { + if (!_isConnected) return SyncResult(status: SyncStatus.invalid); + debugPrint("下载日记 $diaryId (未实现)"); + return SyncResult(status: SyncStatus.failure); + } + + @override + Future uploadDiary({ + required Diary diary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int p1, int p2)? onProgress, + }) async { + if (!_isConnected) return SyncResult(status: SyncStatus.invalid); + debugPrint("上传日记 ${diary.id} (未实现)"); + return SyncResult(status: SyncStatus.failure); + } + + void dispose() { + stopPolling(); + } + + @override + Future updateDiary({ + required Diary oldDiary, + required Diary newDiary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int p1, int p2)? onProgress, + }) { + // TODO: implement updateDiary + throw UnimplementedError(); + } + + @override + Future syncDiary({ + required List diaries, + VoidCallback? onUpload, + VoidCallback? onDownload, + VoidCallback? onComplete, + Function(int p1, int p2)? onProgress, + }) async { + throw UnimplementedError(); + } + + @override + // TODO: implement hasConfig + bool get hasConfig => throw UnimplementedError(); + + @override + // TODO: implement connectivity + ConnectivityStatus get rxConnectivity => _connectivity.value; +} diff --git a/lib/services/sync/sync.dart b/lib/services/sync/sync.dart new file mode 100644 index 0000000..93aea01 --- /dev/null +++ b/lib/services/sync/sync.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:moodiary/common/models/isar/diary.dart'; +import 'package:moodiary/common/models/sync/sync.dart'; +import 'package:moodiary/common/values/sync_status.dart'; +import 'package:moodiary/services/sync/impl/webdav_impl.dart'; +import 'package:refreshed/refreshed.dart'; + +/// 同步服务的抽象基类 +abstract class SyncService { + /// 是否有配置 + bool get hasConfig; + + /// 连通性 + /// 返回值为 [ConnectivityStatus] + /// 这是一个响应式变量,可以通过 [Obx] 进行监听 + ConnectivityStatus get rxConnectivity; + + /// 初始化方法 + Future init(); + + /// 连通性检查 + Future checkConnectivity(); + + /// 同步日记 + /// 这个方法会自动进行增量同步 + Future syncDiary({ + required List diaries, + VoidCallback? onUpload, + VoidCallback? onDownload, + VoidCallback? onComplete, + Function(int, int)? onProgress, + }); + + /// 上传日记 + /// 可选参数为加密 + /// 返回值 [SyncResult] 为同步结果 + /// [onStart] 为开始回调 + /// [onComplete] 为完成回调 + /// [onProgress] 为进度回调 + Future uploadDiary({ + required Diary diary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int, int)? onProgress, + }); + + /// 更新日记 + /// 可选参数为加密 + /// 返回值 [SyncResult] 为同步结果 + /// [onStart] 为开始回调 + /// [onComplete] 为完成回调 + /// [onProgress] 为进度回调 + Future updateDiary({ + required Diary oldDiary, + required Diary newDiary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int, int)? onProgress, + }); + + /// 下载日记 + /// 返回值 [SyncResult] 为同步结果 + /// [onStart] 为开始回调 + /// [onComplete] 为完成回调 + /// [onProgress] 为进度回调 + Future downloadDiary({ + required String diaryId, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int, int)? onProgress, + }); + + /// 删除日记 + /// 返回值 [SyncResult] 为同步结果 + /// [onStart] 为开始回调 + /// [onComplete] 为完成回调 + /// [onProgress] 为进度回调 + Future deleteDiary({ + required Diary diary, + VoidCallback? onStart, + VoidCallback? onComplete, + Function(int, int)? onProgress, + }); + + /// 获取服务器同步数据 + /// 文件名为 [sync.json] + /// 返回值 [SyncResult] 为同步结果 + /// [Map] 为同步数据 + Future>> fetchServerSyncData(); + + /// 更新服务器同步数据 + Future updateServerSyncData(Map syncData); +} + +/// 同步服务的具体实现 +/// 通过 [WebdavSyncServiceImpl] 进行同步 +class WebdavSyncService extends GetxService { + late final SyncService _webdavSyncService; + + Future init({ + required String baseUrl, + required String username, + required String password, + }) async { + _webdavSyncService = WebdavSyncServiceImpl( + url: baseUrl, + username: username, + password: password, + ); + await _webdavSyncService.init(); + } + + @override + void onInit() { + // TODO: implement onInit + super.onInit(); + } + + @override + void onClose() { + // TODO: implement onClose + super.onClose(); + } + + @override + void onReady() { + // TODO: implement onReady + super.onReady(); + } +} + +// /// 通过 MinIO 对象存储进行同步 +// /// 通过 [MinioSyncServiceImpl] 进行同步 +// class MinioSyncService extends GetxService { +// late final SyncService _minioSyncService; +// +// Future init({ +// required String endPoint, +// required String accessKey, +// required String secretKey, +// required String bucketName, +// }) async { +// _minioSyncService = MinioSyncServiceImpl( +// endPoint: endPoint, +// accessKey: accessKey, +// secretKey: secretKey, +// bucketName: bucketName, +// ); +// await _minioSyncService.init(); +// } +// } diff --git a/lib/utils/webdav_util.dart b/lib/utils/webdav_util.dart index a441f76..092213a 100644 --- a/lib/utils/webdav_util.dart +++ b/lib/utils/webdav_util.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart' as flutter; import 'package:moodiary/common/models/isar/category.dart'; @@ -9,6 +10,8 @@ import 'package:moodiary/common/values/webdav.dart'; import 'package:moodiary/pages/home/diary/diary_logic.dart'; import 'package:moodiary/presentation/isar.dart'; import 'package:moodiary/presentation/pref.dart'; +import 'package:moodiary/presentation/secure_storage.dart'; +import 'package:moodiary/utils/aes_util.dart'; import 'package:moodiary/utils/file_util.dart'; import 'package:moodiary/utils/log_util.dart'; import 'package:refreshed/refreshed.dart'; @@ -40,7 +43,6 @@ class WebDavUtil { _client = null; } // 尝试连接,如果失败, - try { _client = webdav.newClient( webDavOption[0], @@ -134,6 +136,7 @@ class WebDavUtil { await updateServerSyncData(serverSyncData); // 删除日记json await _client!.remove('${WebDavOptions.diaryPath}/${diary.id}.json'); + await _client!.remove('${WebDavOptions.diaryPath}/${diary.id}.bin'); // 遍历删除日记资源文件 await _deleteFiles( diary.imageName, '${WebDavOptions.imagePath}/${diary.id}', 'image'); @@ -341,7 +344,30 @@ class WebDavUtil { } } + Future _checkShouldEncrypt() async { + return PrefUtil.getValue('syncEncryption') == true && + (await SecureStorageUtil.getValue('userKey')) != null; + } + Future _uploadDiary(Diary diary) async { + Uint8List diaryData; + String diaryPath; + // 检查有没有开启加密 + final shouldEncrypt = await _checkShouldEncrypt(); + if (shouldEncrypt) { + // 尝试获取用户密钥 + final userKey = await SecureStorageUtil.getValue('userKey'); + // 生成加密密钥, 用日记 ID 和用户密钥生成 + final key = await AesUtil.deriveKey(salt: diary.id, userKey: userKey!); + // 加密日记内容 + diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.bin'; + diaryData = + await AesUtil.encrypt(key: key, data: jsonEncode(diary.toJson())); + } else { + diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.json'; + diaryData = utf8.encode(jsonEncode(diary.toJson())); + } + // 检查并上传分类 if (diary.categoryId != null) { final categoryName = @@ -350,20 +376,16 @@ class WebDavUtil { await _uploadCategory(diary.categoryId!, categoryName); } } - - // 上传日记 JSON 数据 - final diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.json'; - final diaryData = jsonEncode(diary.toJson()); - LogUtil.printInfo(diaryData); try { _client!.setHeaders({ 'accept-charset': 'utf-8', - 'Content-Type': 'application/json', + 'Content-Type': + shouldEncrypt ? 'application/octet-stream' : 'application/json', }); - await _client!.write(diaryPath, utf8.encode(diaryData)); - LogUtil.printInfo('Diary JSON uploaded: $diaryPath'); + await _client!.write(diaryPath, diaryData); + LogUtil.printInfo('Diary uploaded: $diaryPath'); } catch (e) { - LogUtil.printInfo('Failed to upload diary JSON: $e'); + LogUtil.printInfo('Failed to upload diary : $e'); rethrow; } @@ -422,17 +444,43 @@ class WebDavUtil { Future _downloadDiary(String diaryId) async { // 下载日记 JSON 数据 - final diaryPath = '${WebDavOptions.diaryPath}/$diaryId.json'; + final normalDiaryPath = '${WebDavOptions.diaryPath}/$diaryId.json'; + final encryptedDiaryPath = '${WebDavOptions.diaryPath}/$diaryId.bin'; late Diary diary; - try { - final diaryData = await _client!.read(diaryPath); - diary = await flutter.compute(Diary.fromJson, - jsonDecode(utf8.decode(diaryData)) as Map); - LogUtil.printInfo('Diary JSON downloaded: $diaryPath'); + // 先尝试普通 JSON 格式 + try { + final diaryData = await _client!.read(normalDiaryPath); + diary = await flutter.compute(Diary.fromJson, + jsonDecode(utf8.decode(diaryData)) as Map); + LogUtil.printInfo('Diary JSON downloaded: $normalDiaryPath'); + } catch (e) { + LogUtil.printInfo('Failed to download normal JSON: $e'); + // 再尝试二进制格式 + try { + final encryptedDiaryData = await _client!.read(encryptedDiaryPath); + // 解密日记内容 + final userKey = await SecureStorageUtil.getValue('userKey'); + final shouldEncrypt = await _checkShouldEncrypt(); + if (!shouldEncrypt) { + throw Exception('User key not found or encryption not enabled'); + } + final key = await AesUtil.deriveKey(salt: diaryId, userKey: userKey!); + final decryptedData = await AesUtil.decrypt( + key: key, + encryptedData: Uint8List.fromList(encryptedDiaryData), + ); + diary = await flutter.compute(Diary.fromJson, + jsonDecode(decryptedData) as Map); + LogUtil.printInfo('Diary binary downloaded: $encryptedDiaryPath'); + } catch (e) { + LogUtil.printInfo('Failed to download binary diary: $e'); + // 两种方式都失败,抛出最终异常 + rethrow; + } + } } catch (e) { - LogUtil.printInfo('Failed to download diary JSON: $e'); - rethrow; + throw Exception('Failed to download diary: $e'); } // 同步分类