import 'package:crypto/crypto.dart'; import 'package:isar/isar.dart'; import 'package:mangayomi/eval/model/m_bridge.dart'; import 'package:mangayomi/eval/model/source_preference.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/changed_items.dart'; import 'package:mangayomi/models/update.dart'; import 'package:mangayomi/models/sync_preference.dart'; import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/category.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/history.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/source.dart'; import 'package:mangayomi/modules/more/settings/sync/models/jwt.dart'; import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/blend_level_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/flex_scheme_color_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/theme_mode_state_provider.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'dart:convert'; import 'package:mangayomi/services/http/m_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; part 'sync_server.g.dart'; @riverpod class SyncServer extends _$SyncServer { final http = MClient.init(reqcopyWith: {'useDartHttpClient': true}); final String _loginUrl = '/login'; final String _checkUrl = '/check'; final String _syncUrl = '/sync'; final String _uploadUrl = '/upload/full'; final String _downloadUrl = '/download'; @override void build({required int syncId}) {} Future<(bool, String)> login(AppLocalizations l10n, String server, String username, String password) async { server = server[server.length - 1] == '/' ? server.substring(0, server.length - 1) : server; try { var response = await http.post( Uri.parse('$server$_loginUrl'), headers: { 'Content-Type': 'application/json', }, body: jsonEncode({'email': username, 'password': password}), ); var jsonData = jsonDecode(response.body) as Map; if (response.statusCode != 200) { return (false, jsonData["error"] as String); } ref .read(synchingProvider(syncId: syncId).notifier) .login(SyncPreference(syncId: syncId, email: username, server: server, authToken: jsonData["token"])); botToast(l10n.sync_logged); return (true, ""); } catch (e) { return (false, e.toString()); } } Future checkForSync(bool silent) async { if (!silent) { botToast("Checking for sync...", second: 2); } try { final datas = _getData(); final accessToken = _getAccessToken(); final localHash = _getDataHash(datas); var response = await http.get( Uri.parse('${_getServer()}$_checkUrl'), headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $accessToken'}, ); if (response.statusCode != 200) { botToast("Check failed", second: 5); return; } var jsonData = jsonDecode(response.body) as Map; final remoteHash = jsonData["hash"]; if (localHash != remoteHash) { syncToServer(silent); } else if (!silent) { botToast("Sync up to date", second: 2); } } catch (error) { botToast(error.toString(), second: 5); } } Future syncToServer(bool silent) async { if (!silent) { botToast("Sync started...", second: 2); } try { final datas = _getData(); final accessToken = _getAccessToken(); var response = await http.post( Uri.parse('${_getServer()}$_syncUrl'), headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $accessToken'}, body: jsonEncode({'backupData': datas, 'changedItems': _getChangedData()}), ); if (response.statusCode != 200) { botToast("Sync failed", second: 5); return; } var jsonData = jsonDecode(response.body) as Map; final decodedBackupData = jsonData["backupData"] is String ? jsonDecode(jsonData["backupData"]) : jsonData["backupData"]; _restoreMerge(decodedBackupData); ref.read(synchingProvider(syncId: syncId).notifier).setLastSync(DateTime.now().millisecondsSinceEpoch); ref.read(changedItemsManagerProvider(managerId: 1).notifier).cleanChangedItems(true); if (!silent) { botToast("Sync finished", second: 2); } } catch (error) { botToast(error.toString(), second: 5); } } Future uploadToServer(AppLocalizations l10n) async { botToast(l10n.sync_uploading, second: 2); try { final datas = _getData(); final accessToken = _getAccessToken(); var response = await http.post( Uri.parse('${_getServer()}$_uploadUrl'), headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $accessToken'}, body: jsonEncode({'backupData': datas}), ); if (response.statusCode != 200) { botToast(l10n.sync_upload_failed, second: 5); return; } ref.read(synchingProvider(syncId: syncId).notifier).setLastUpload(DateTime.now().millisecondsSinceEpoch); ref.read(changedItemsManagerProvider(managerId: 1).notifier).cleanChangedItems(true); botToast(l10n.sync_upload_finished, second: 2); } catch (error) { botToast(error.toString(), second: 5); } } Future downloadFromServer(AppLocalizations l10n) async { botToast(l10n.sync_downloading, second: 2); try { final accessToken = _getAccessToken(); var response = await http.get( Uri.parse('${_getServer()}$_downloadUrl'), headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $accessToken'}, ); if (response.statusCode != 200) { botToast(l10n.sync_download_failed, second: 5); return; } var jsonData = jsonDecode(response.body) as Map; _restore(jsonData["backupData"] is String ? jsonDecode(jsonData["backupData"]) : jsonData["backupData"]); ref.read(synchingProvider(syncId: syncId).notifier).setLastDownload(DateTime.now().millisecondsSinceEpoch); ref.read(changedItemsManagerProvider(managerId: 1).notifier).cleanChangedItems(true); botToast(l10n.sync_download_finished, second: 2); } catch (error) { botToast(error.toString(), second: 5); } } String _getDataHash(Map data) { Map datas = {}; datas["version"] = data["version"]; datas["manga"] = data["manga"]; datas["categories"] = data["categories"]; datas["chapters"] = data["chapters"]; datas["tracks"] = data["tracks"]; datas["history"] = data["history"]; datas["updates"] = data["updates"]; var encodedJson = jsonEncode(datas); return sha256.convert(utf8.encode(encodedJson)).toString(); } Map _getChangedData() { Map data = {}; final changedItems = isar.changedItems.getSync(1); if (changedItems != null) { data.addAll({"deletedMangas": changedItems.deletedMangas?.map((e) => e.toJson()).toList() ?? []}); data.addAll({"updatedChapters": changedItems.updatedChapters?.map((e) => e.toJson()).toList() ?? []}); data.addAll({"deletedCategories": changedItems.deletedCategories?.map((e) => e.toJson()).toList() ?? []}); } return data; } Map _getData() { Map datas = {}; datas.addAll({"version": "1"}); final mangas = isar.mangas .filter() .idIsNotNull() .favoriteEqualTo(true) .isLocalArchiveEqualTo(false) .findAllSync() .map((e) => e.toJson()) .toList(); datas.addAll({"manga": mangas}); final categorys = isar.categorys.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"categories": categorys}); final chapters = isar.chapters.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"chapters": chapters}); datas.addAll({"downloads": []}); final tracks = isar.tracks.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"tracks": tracks}); datas.addAll({"trackPreferences": []}); final historys = isar.historys.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"history": historys}); final settings = isar.settings.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"settings": settings}); final sources = isar.sources.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"extensions": sources}); final sourcePreferences = isar.sourcePreferences.filter().idIsNotNull().keyIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"extensions_preferences": sourcePreferences}); final updates = isar.updates.filter().idIsNotNull().findAllSync().map((e) => e.toJson()).toList(); datas.addAll({"updates": updates}); return datas; } void _restoreMerge(Map backup) { if (backup['version'] == "1") { try { final manga = (backup["manga"] as List?)?.map((e) => Manga.fromJson(e)).toList(); final chapters = (backup["chapters"] as List?)?.map((e) => Chapter.fromJson(e)).toList(); final categories = (backup["categories"] as List?)?.map((e) => Category.fromJson(e)).toList(); final track = (backup["tracks"] as List?)?.map((e) => Track.fromJson(e)).toList(); final history = (backup["history"] as List?)?.map((e) => History.fromJson(e)).toList(); final updates = (backup["updates"] as List?)?.map((e) => Update.fromJson(e)).toList(); isar.writeTxnSync(() { isar.mangas.clearSync(); if (manga != null) { isar.mangas.putAllSync(manga); if (chapters != null) { isar.chapters.clearSync(); for (var chapter in chapters) { final manga = isar.mangas.getSync(chapter.mangaId!); if (manga != null) { isar.chapters.putSync(chapter..manga.value = manga); chapter.manga.saveSync(); } } isar.historys.clearSync(); if (history != null) { for (var element in history) { final chapter = isar.chapters.getSync(element.chapterId!); if (chapter != null) { isar.historys.putSync(element..chapter.value = chapter); element.chapter.saveSync(); } } } isar.updates.clearSync(); if (updates != null) { final tempChapters = isar.chapters.filter().idIsNotNull().findAllSync().toList(); for (var update in updates) { final matchingChapter = tempChapters .where((chapter) => chapter.mangaId == update.mangaId && chapter.name == update.chapterName) .firstOrNull; if (matchingChapter != null) { isar.updates.putSync(update..chapter.value = matchingChapter); update.chapter.saveSync(); } } } } isar.categorys.clearSync(); if (categories != null) { isar.categorys.putAllSync(categories); } } isar.tracks.clearSync(); if (track != null) { isar.tracks.putAllSync(track); } ref.invalidate(themeModeStateProvider); ref.invalidate(blendLevelStateProvider); ref.invalidate(flexSchemeColorStateProvider); ref.invalidate(pureBlackDarkModeStateProvider); ref.invalidate(l10nLocaleStateProvider); }); } catch (e) { botToast(e.toString()); } } } void _restore(Map backup) { if (backup['version'] == "1") { try { final manga = (backup["manga"] as List?)?.map((e) => Manga.fromJson(e)).toList(); final chapters = (backup["chapters"] as List?)?.map((e) => Chapter.fromJson(e)).toList(); final categories = (backup["categories"] as List?)?.map((e) => Category.fromJson(e)).toList(); final track = (backup["tracks"] as List?)?.map((e) => Track.fromJson(e)).toList(); final history = (backup["history"] as List?)?.map((e) => History.fromJson(e)).toList(); final settings = (backup["settings"] as List?)?.map((e) => Settings.fromJson(e)).toList(); final extensions = (backup["extensions"] as List?)?.map((e) => Source.fromJson(e)).toList(); final extensionsPref = (backup["extensions_preferences"] as List?)?.map((e) => SourcePreference.fromJson(e)).toList(); final updates = (backup["updates"] as List?)?.map((e) => Update.fromJson(e)).toList(); isar.writeTxnSync(() { isar.mangas.clearSync(); if (manga != null) { isar.mangas.putAllSync(manga); if (chapters != null) { isar.chapters.clearSync(); for (var chapter in chapters) { final manga = isar.mangas.getSync(chapter.mangaId!); if (manga != null) { isar.chapters.putSync(chapter..manga.value = manga); chapter.manga.saveSync(); } } isar.historys.clearSync(); if (history != null) { for (var element in history) { final chapter = isar.chapters.getSync(element.chapterId!); if (chapter != null) { isar.historys.putSync(element..chapter.value = chapter); element.chapter.saveSync(); } } } isar.updates.clearSync(); if (updates != null) { final tempChapters = isar.chapters.filter().idIsNotNull().findAllSync().toList(); for (var update in updates) { final matchingChapter = tempChapters .where((chapter) => chapter.mangaId == update.mangaId && chapter.name == update.chapterName) .firstOrNull; if (matchingChapter != null) { isar.updates.putSync(update..chapter.value = matchingChapter); update.chapter.saveSync(); } } } } isar.categorys.clearSync(); if (categories != null) { isar.categorys.putAllSync(categories); } } isar.tracks.clearSync(); if (track != null) { isar.tracks.putAllSync(track); } isar.sources.clearSync(); if (extensions != null) { isar.sources.putAllSync(extensions); } isar.sourcePreferences.clearSync(); if (extensionsPref != null) { isar.sourcePreferences.putAllSync(extensionsPref); } final syncAfterReading = isar.settings.getSync(227)!.syncAfterReading; final syncOnAppLaunch = isar.settings.getSync(227)!.syncOnAppLaunch; isar.settings.clearSync(); if (settings != null) { isar.settings.putAllSync(settings); } if (isar.settings.getSync(227) == null) { isar.settings.putSync(Settings(id: 227)); } isar.settings.putSync(isar.settings.getSync(227)!..syncAfterReading = syncAfterReading); isar.settings.putSync(isar.settings.getSync(227)!..syncOnAppLaunch = syncOnAppLaunch); ref.invalidate(themeModeStateProvider); ref.invalidate(blendLevelStateProvider); ref.invalidate(flexSchemeColorStateProvider); ref.invalidate(pureBlackDarkModeStateProvider); ref.invalidate(l10nLocaleStateProvider); }); } catch (e) { botToast(e.toString(), second: 5); } } } String _getAccessToken() { final syncPrefs = ref.watch(synchingProvider(syncId: syncId)); if (syncPrefs == null || syncPrefs.authToken == null) { return ""; } var paddedPayload = syncPrefs.authToken!.split(".")[1]; if (paddedPayload.length % 4 > 0) { paddedPayload += '=' * (4 - paddedPayload.length % 4); } final decodedJwt = jsonDecode(utf8.decode(base64Decode(paddedPayload))) as Map; final auth = JWToken.fromJson(decodedJwt); final expiresIn = DateTime.fromMillisecondsSinceEpoch(auth.exp!); if (DateTime.now().isAfter(expiresIn)) { ref.read(synchingProvider(syncId: syncId).notifier).logout(); botToast("SyncServer Token expired"); throw Exception("Token expired"); } return syncPrefs.authToken!; } String _getServer() { final syncPrefs = ref.watch(synchingProvider(syncId: syncId)); return syncPrefs?.server ?? ""; } }