import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:eh_downloader_flutter/l10n_gen/app_localizations.dart'; import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; import 'config.dart'; import 'eh.dart'; import 'file.dart'; import 'gallery.dart'; import 'log.dart'; import 'status.dart'; import 'tags.dart'; import 'task.dart'; import 'token.dart'; import 'user.dart'; import '../globals.dart'; part 'client.g.dart'; final _pbkdf2a = Pbkdf2( macAlgorithm: Hmac.sha512(), iterations: 210000, bits: 512, ); final _pbkdf2b = Pbkdf2( macAlgorithm: Hmac.sha512(), iterations: 1000, bits: 512, ); const _utf8Encoder = Utf8Encoder(); final _salt = _utf8Encoder.convert("eh-downloader-salt"); enum ThumbnailGenMethod { unknown, cover, contain, fill; String localText(BuildContext context) { final i18n = AppLocalizations.of(context)!; switch (this) { case ThumbnailGenMethod.unknown: return i18n.default0; case ThumbnailGenMethod.cover: return i18n.thumbnailCover; case ThumbnailGenMethod.contain: return i18n.thumbnailContain; case ThumbnailGenMethod.fill: return i18n.thumbnailFill; } } } enum ThumbnailAlign { left, center, right; static const top = left; static const bottom = right; String localText(BuildContext context) { final i18n = AppLocalizations.of(context)!; switch (this) { case ThumbnailAlign.left: return i18n.leftOrTop; case ThumbnailAlign.center: return i18n.center; case ThumbnailAlign.right: return i18n.rightOrBottom; } } } enum SortByGid { none, asc, desc; bool? toBool() { switch (this) { case SortByGid.asc: return true; case SortByGid.desc: return false; default: return null; } } } @RestApi() abstract class _EHApi { factory _EHApi(Dio dio, {required String baseUrl}) = __EHApi; @POST('/user/change_name') @MultiPart() Future> changeUserName( @Part(name: "username") String username, {@CancelRequest() CancelToken? cancel}); @POST('/user/change_password') @MultiPart() // ignore: unused_element Future> _changeUserPassword( @Part(name: "old") String oldPassword, @Part(name: "t") int t, @Part(name: "new") String newPassword, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @PUT('/user') @MultiPart() Future> createUser( @Part(name: "name") String name, @Part(name: "password") String password, {@Part(name: "is_admin") bool? isAdmin, @Part(name: "permissions") int? permissions, @CancelRequest() CancelToken? cancel}); @DELETE('/user') @MultiPart() Future> deleteUser( {@Part(name: "id") int? id, @Part(name: "username") String? username, @CancelRequest() CancelToken? cancel}); @GET('/user') Future> getUser( {@Query("id") int? id, @Query("username") String? username, @CancelRequest() CancelToken? cancel}); @GET('/user/list') Future>> getUsers( {@Query("all") bool? all, @Query("offset") int? offset, @Query("limit") int? limit, @CancelRequest() CancelToken? cancel}); @PATCH('/user') @MultiPart() Future> updateUser( {@Part(name: "id") int? id, @Part(name: "username") String? username, @Part(name: "password") String? password, @Part(name: "is_admin") bool? isAdmin, @Part(name: "revoke_token") bool? revokeToken, @Part(name: "permissions") int? permissions, @CancelRequest() CancelToken? cancel}); @GET('/status') Future> getStatus( {@CancelRequest() CancelToken? cancel}); @PUT('/token') @MultiPart() // ignore: unused_element Future> _createToken( {@Part(name: "username") required String username, @Part(name: "password") required String password, @Part(name: "t") required int t, // ignore: unused_element @Part(name: "set_cookie") bool? setCookie, // ignore: unused_element @Part(name: "http_only") bool? httpOnly, // ignore: unused_element @Part(name: "secure") bool? secure, // ignore: unused_element @Part(name: "client") String? client, // ignore: unused_element @Part(name: "device") String? device, // ignore: unused_element @Part(name: "client_version") String? clientVersion, // ignore: unused_element @Part(name: "client_platform") String? clientPlatform, // ignore: unused_element @CancelRequest() CancelToken? cancel}); @PATCH('/token') @MultiPart() Future> updateToken( {@Part(name: "token") String? token, @Part(name: "client") String? client, @Part(name: "device") String? device, @Part(name: "client_version") String? clientVersion, @Part(name: "client_platform") String? clientPlatform, @CancelRequest() CancelToken? cancel}); @DELETE('/token') @MultiPart() Future> deleteToken( {@Part(name: "token") String? token, @CancelRequest() CancelToken? cancel}); @DELETE('/token/manage') @MultiPart() Future> deleteTokenById(@Part(name: "id") int id, {@CancelRequest() CancelToken? cancel}); @GET('/token') Future> getToken( {@Query("token") String? token, @CancelRequest() CancelToken? cancel}); @GET('/token/manage') Future>> getTokens( {@Query("uid") int? uid, @Query("offset") int? offset, @Query("limit") int? limit, @Query("all_user") bool? allUser, @CancelRequest() CancelToken? cancel}); @GET('/shared_token') Future> getSharedToken( {@CancelRequest() CancelToken? cancel}); @GET('/file/{id}') @DioResponseType(ResponseType.bytes) Future>> getFile(@Path("id") int id, {@CancelRequest() CancelToken? cancel}); @GET('/file/{id}') // ignore: unused_element Future> _getFileData( @Path("id") int id, @Query("data") bool data, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @GET('/file/random') @DioResponseType(ResponseType.bytes) Future>> getRandomFile( {@Query("is_nsfw") bool? isNsfw, @Query("is_ad") bool? isAd, @Query("thumb") bool? thumb, @CancelRequest() CancelToken? cancel}); @GET('/files/{token}') // ignore: unused_element Future> _getFiles(@Path("token") String token, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @GET('/thumbnail/{id}') @DioResponseType(ResponseType.bytes) Future>> getThumbnail(@Path("id") int id, {@Query("max") int? max, @Query("width") int? width, @Query("height") int? height, @Query("quality") int? quality, @Query("force") bool? force, @Query("method") ThumbnailGenMethod? method, @Query("align") ThumbnailAlign? align, @CancelRequest() CancelToken? cancel}); @GET('/gallery/{gid}') Future> getGallery(@Path("gid") int gid, {@CancelRequest() CancelToken? cancel}); @GET('/gallery/meta/{gids}') // ignore: unused_element Future> _getGalleriesMeta(@Path("gids") String gids, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @GET('/gallery/thumbnail/{gids}') // ignore: unused_element Future> _getGalleriesThumbnail( @Path("gids") String gids, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @GET('/gallery/list') Future>> listGalleries( {@Query("all") bool? all, @Query("offset") int? offset, @Query("limit") int? limit, @Query("sort_by_gid") bool? sortByGid, @Query("uploader") String? uploader, @Query("tag") String? tag, @Query("category") String? category, @CancelRequest() CancelToken? cancel}); @DELETE('/shared_token') @MultiPart() Future> deleteSharedToken( @Part(name: "token") String token, @Part(name: "type") String type, {@CancelRequest() CancelToken? cancel}); @PUT('/shared_token') @MultiPart() Future> shareGallery(@Part(name: "gid") int gid, {@Part(name: "expired") int? expired, @Part(name: "type") String type = "gallery", @CancelRequest() CancelToken? cancel}); @PATCH('/shared_token') @MultiPart() Future> updateShareGallery( @Part(name: "token") String token, {@Part(name: "expired") int? expired, @Part(name: "type") String type = "gallery", @CancelRequest() CancelToken? cancel}); @GET('/shared_token/list') Future>> listShareGalleries( {@Query("gid") int? gid, @Query("type") String type = "gallery", @CancelRequest() CancelToken? cancel}); @GET('/tag/{id}') // ignore: unused_element Future> _getTags(@Path("id") String id, // ignore: unused_element {@CancelRequest() CancelToken? cancel}); @GET('/tag/rows') Future>> getRowTags( {@CancelRequest() CancelToken? cancel}); @GET('/export/gallery/zip/{gid}') @DioResponseType(ResponseType.stream) Future exportGalleryZip(@Path("gid") int gid, {@Query("jpn_title") bool? jpnTitle, @Query("max_length") int? maxLength, @Query("export_ad") bool? exportAd, @CancelRequest() CancelToken? cancel}); @POST('/filemeta') @MultiPart() Future> updateGalleryFileMeta(@Part(name: "gid") int gid, {@Part(name: "is_nsfw") bool? isNsfw, @Part(name: "is_ad") bool? isAd, @Part(name: "excludes") String? excludes, @CancelRequest() CancelToken? cancel}); @POST('/filemeta') @MultiPart() Future> updateFileMeta(@Part(name: "token") String token, {@Part(name: "is_nsfw") bool? isNsfw, @Part(name: "is_ad") bool? isAd, @CancelRequest() CancelToken? cancel}); @POST('/filemeta') @MultiPart() Future> updateFilesMeta( @Part(name: "tokens") String tokens, {@Part(name: "is_nsfw") bool? isNsfw, @Part(name: "is_ad") bool? isAd, @CancelRequest() CancelToken? cancel}); @GET('/config') Future getConfig( {@Query("current") bool? current, @CancelRequest() CancelToken? cancel}); @POST('/config') Future updateConfig( @Body(nullToAbsent: false) ConfigOptional cfg, {@CancelRequest() CancelToken? cancel}); @GET('/eh/metadata') Future> getMetaInfo( @Query("gid") List gids, @Query("token") List tokens, {@CancelRequest() CancelToken? cancel}); @PUT('/task') @MultiPart() Future> createDownloadTask( @Part(name: "gid") int gid, @Part(name: "token") String token, { @Part(name: "cfg") DownloadConfig? cfg, @Part(name: "type") String t = "download", @CancelRequest() CancelToken? cancel, }); @PUT('/task') @MultiPart() Future> createExportZipTask(@Part(name: "gid") int gid, {@Part(name: 'cfg') ExportZipConfig? cfg, @Part(name: "type") String t = "export_zip", @CancelRequest() CancelToken? cancel}); @PUT('/task') @MultiPart() Future> createImportTask( @Part(name: "gid") int gid, @Part(name: "token") String token, { @Part(name: "cfg") ImportConfig? cfg, @Part(name: "type") String t = "import", @CancelRequest() CancelToken? cancel, }); @PUT('/task') @MultiPart() Future> createUpdateMeiliSearchDataTask({ @Part(name: "gid") int? gid, @Part(name: "type") String t = "update_meili_search_data", @CancelRequest() CancelToken? cancel, }); @PUT('/task') @MultiPart() Future> createUpdateTagTranslationTask({ @Part(name: "cfg") UpdateTagTranslationConfig? cfg, @Part(name: "type") String t = "update_tag_translation", @CancelRequest() CancelToken? cancel, }); @GET('/task/download_cfg') Future> getDefaultDownloadConfig( {@CancelRequest() CancelToken? cancel}); @GET('/task/export_zip_cfg') Future> getDefaultExportZipConfig( {@CancelRequest() CancelToken? cancel}); @GET('/task/import_cfg') Future> getDefaultImportConfig( {@CancelRequest() CancelToken? cancel}); @GET('/log') Future> queryLog( {@Query("page") int? page, @Query("limit") int? limit, @Query("offset") int? offset, @Query("type") String? type, @Query("min_level") int? minLevel, @Query("allowed_level") String? allowedLevel, @CancelRequest() CancelToken? cancel}); @DELETE('/log') @MultiPart() Future> clearLog( {@Part(name: "type") String? type, @Part(name: "min_level") int? minLevel, @Part(name: "max_level") int? maxLevel, @Part(name: "deleted_level") String? deletedLevel, @Part(name: "end_time") String? endTime, @CancelRequest() CancelToken? cancel}); @GET('/log/{id}') Future> getLog(@Path("id") int id, {@CancelRequest() CancelToken? cancel}); @DELETE('/log/{id}') @MultiPart() Future> deleteLog(@Path("id") int id, {@CancelRequest() CancelToken? cancel}); } class EHApi extends __EHApi { EHApi(super.dio, {required String super.baseUrl}); Future> createToken( {required String username, required String password, bool? setCookie, bool? httpOnly, bool? secure, String? client, String? device, String? clientVersion, String? clientPlatform, CancelToken? cancel}) async { int t = DateTime.now().millisecondsSinceEpoch; final p = await _pbkdf2a.deriveKeyFromPassword(password: password, nonce: _salt); final p2 = await _pbkdf2b.deriveKey( secretKey: p, nonce: _utf8Encoder.convert(t.toString())); final p3 = base64Encode(await p2.extractBytes()); return await _createToken( username: username, password: p3, t: t, setCookie: setCookie, httpOnly: httpOnly, secure: secure, client: client, device: device, clientVersion: clientVersion, clientPlatform: clientPlatform, cancel: cancel); } Future> getFileData(int id, {CancelToken? cancel}) { return _getFileData(id, true, cancel: cancel); } Future> getFiles(List tokens, {CancelToken? cancel}) { return _getFiles(tokens.join(","), cancel: cancel); } Future> getGalleriesMeta(List gids, {@CancelRequest() CancelToken? cancel}) { return _getGalleriesMeta(gids.join(","), cancel: cancel); } Future> getGalleriesThumbnail(List gids, {CancelToken? cancel}) { return _getGalleriesThumbnail(gids.join(","), cancel: cancel); } Future> getTags(List ids, {CancelToken? cancel}) { return _getTags(ids.join(","), cancel: cancel); } Future> getTags2(List ids, {CancelToken? cancel}) { return _getTags(ids.join(","), cancel: cancel); } String getFileUrl(int id) { final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); final newUri = uri.resolve("file/$id"); return newUri.toString(); } Uri getTaskUrl() { final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); final nuri = uri.resolve("task"); return Uri( scheme: uri.scheme == "https" ? "wss" : "ws", userInfo: nuri.userInfo, host: nuri.host, port: nuri.port, path: nuri.path, query: nuri.query, ); } String getThumbnailUrl(int id, {int? max, int? width, int? height, int? quality, bool? force, ThumbnailGenMethod? method, ThumbnailAlign? align}) { final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); final queryParameters = { r'max': max?.toString(), r'width': width?.toString(), r'height': height?.toString(), r'quality': quality?.toString(), r'force': force?.toString(), r'method': method?.name, r'align': align?.name, }; queryParameters.removeWhere((k, v) => v == null); final newUri = uri.resolve("thumbnail/$id").replace(queryParameters: queryParameters); return newUri.toString(); } String exportGalleryZipUrl(int gid, {bool? jpnTitle, int? maxLength, bool? exportAd}) { final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); var queries = { "jpn_title": jpnTitle?.toString(), "max_length": maxLength?.toString(), "export_ad": exportAd?.toString(), "share": shareToken, }; queries.removeWhere((key, value) => value == null); final newUri = uri .resolve("export/gallery/zip/$gid") .replace(queryParameters: queries); return newUri.toString(); } Future> changeUserPassword( String oldPassword, String newPassword, {CancelToken? cancel}) async { int t = DateTime.now().millisecondsSinceEpoch; final p = await _pbkdf2a.deriveKeyFromPassword( password: oldPassword, nonce: _salt); final p2 = await _pbkdf2b.deriveKey( secretKey: p, nonce: _utf8Encoder.convert(t.toString())); final p3 = base64Encode(await p2.extractBytes()); return await _changeUserPassword(p3, t, newPassword, cancel: cancel); } }