mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
666 lines
20 KiB
Dart
666 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:html/parser.dart' as html;
|
|
import 'package:html/dom.dart' as dom;
|
|
import 'package:flutter_qjs/flutter_qjs.dart';
|
|
import 'package:pointycastle/api.dart';
|
|
import 'package:pointycastle/asn1/asn1_parser.dart';
|
|
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
|
|
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
|
|
import 'package:pointycastle/asymmetric/api.dart';
|
|
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
|
import 'package:pointycastle/asymmetric/rsa.dart';
|
|
import 'package:pointycastle/block/aes.dart';
|
|
import 'package:pointycastle/block/modes/cbc.dart';
|
|
import 'package:pointycastle/block/modes/cfb.dart';
|
|
import 'package:pointycastle/block/modes/ecb.dart';
|
|
import 'package:pointycastle/block/modes/ofb.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:venera/network/app_dio.dart';
|
|
import 'package:venera/network/cookie_jar.dart';
|
|
|
|
import 'comic_source/comic_source.dart';
|
|
import 'consts.dart';
|
|
import 'log.dart';
|
|
|
|
class JavaScriptRuntimeException implements Exception {
|
|
final String message;
|
|
|
|
JavaScriptRuntimeException(this.message);
|
|
|
|
@override
|
|
String toString() {
|
|
return "JSException: $message";
|
|
}
|
|
}
|
|
|
|
class JsEngine with _JSEngineApi {
|
|
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
|
|
|
static JsEngine? _cache;
|
|
|
|
JsEngine._create();
|
|
|
|
FlutterQjs? _engine;
|
|
|
|
bool _closed = true;
|
|
|
|
Dio? _dio;
|
|
|
|
static void reset() {
|
|
_cache = null;
|
|
_cache?.dispose();
|
|
JsEngine().init();
|
|
}
|
|
|
|
Future<void> init() async {
|
|
if (!_closed) {
|
|
return;
|
|
}
|
|
try {
|
|
_dio ??= AppDio(BaseOptions(
|
|
responseType: ResponseType.plain, validateStatus: (status) => true));
|
|
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
|
_closed = false;
|
|
_engine = FlutterQjs();
|
|
_engine!.dispatch();
|
|
var setGlobalFunc =
|
|
_engine!.evaluate("(key, value) => { this[key] = value; }");
|
|
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
|
setGlobalFunc.free();
|
|
var jsInit = await rootBundle.load("assets/init.js");
|
|
_engine!
|
|
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
|
} catch (e, s) {
|
|
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
|
}
|
|
}
|
|
|
|
Object? _messageReceiver(dynamic message) {
|
|
try {
|
|
if (message is Map<dynamic, dynamic>) {
|
|
String method = message["method"] as String;
|
|
switch (method) {
|
|
case "log":
|
|
{
|
|
String level = message["level"];
|
|
Log.addLog(
|
|
switch (level) {
|
|
"error" => LogLevel.error,
|
|
"warning" => LogLevel.warning,
|
|
"info" => LogLevel.info,
|
|
_ => LogLevel.warning
|
|
},
|
|
message["title"],
|
|
message["content"].toString());
|
|
}
|
|
case 'load_data':
|
|
{
|
|
String key = message["key"];
|
|
String dataKey = message["data_key"];
|
|
return ComicSource.find(key)?.data[dataKey];
|
|
}
|
|
case 'save_data':
|
|
{
|
|
String key = message["key"];
|
|
String dataKey = message["data_key"];
|
|
if (dataKey == 'setting') {
|
|
throw "setting is not allowed to be saved";
|
|
}
|
|
var data = message["data"];
|
|
var source = ComicSource.find(key)!;
|
|
source.data[dataKey] = data;
|
|
source.saveData();
|
|
}
|
|
case 'delete_data':
|
|
{
|
|
String key = message["key"];
|
|
String dataKey = message["data_key"];
|
|
var source = ComicSource.find(key);
|
|
source?.data.remove(dataKey);
|
|
source?.saveData();
|
|
}
|
|
case 'http':
|
|
{
|
|
return _http(Map.from(message));
|
|
}
|
|
case 'html':
|
|
{
|
|
return handleHtmlCallback(Map.from(message));
|
|
}
|
|
case 'convert':
|
|
{
|
|
return _convert(Map.from(message));
|
|
}
|
|
case "random":
|
|
{
|
|
return _random(
|
|
message["min"] ?? 0,
|
|
message["max"] ?? 1,
|
|
message["type"],
|
|
);
|
|
}
|
|
case "cookie":
|
|
{
|
|
return handleCookieCallback(Map.from(message));
|
|
}
|
|
case "uuid":
|
|
{
|
|
return const Uuid().v1();
|
|
}
|
|
case "load_setting":
|
|
{
|
|
String key = message["key"];
|
|
String settingKey = message["setting_key"];
|
|
var source = ComicSource.find(key)!;
|
|
return source.data["settings"]?[settingKey] ??
|
|
source.settings?[settingKey]['default'] ??
|
|
(throw "Setting not found: $settingKey");
|
|
}
|
|
case "isLogged":
|
|
{
|
|
return ComicSource.find(message["key"])!.isLogged;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
} catch (e, s) {
|
|
Log.error("Failed to handle message: $message\n$e\n$s", "JsEngine");
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _http(Map<String, dynamic> req) async {
|
|
Response? response;
|
|
String? error;
|
|
|
|
try {
|
|
var headers = Map<String, dynamic>.from(req["headers"] ?? {});
|
|
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
|
headers["User-Agent"] = webUA;
|
|
}
|
|
response = await _dio!.request(req["url"],
|
|
data: req["data"],
|
|
options: Options(
|
|
method: req['http_method'],
|
|
responseType: req["bytes"] == true
|
|
? ResponseType.bytes
|
|
: ResponseType.plain,
|
|
headers: headers));
|
|
} catch (e) {
|
|
error = e.toString();
|
|
}
|
|
|
|
Map<String, String> headers = {};
|
|
|
|
response?.headers
|
|
.forEach((name, values) => headers[name] = values.join(','));
|
|
|
|
dynamic body = response?.data;
|
|
if (body is! Uint8List && body is List<int>) {
|
|
body = Uint8List.fromList(body);
|
|
}
|
|
|
|
return {
|
|
"status": response?.statusCode,
|
|
"headers": headers,
|
|
"body": body,
|
|
"error": error,
|
|
};
|
|
}
|
|
|
|
dynamic runCode(String js, [String? name]) {
|
|
return _engine!.evaluate(js, name: name);
|
|
}
|
|
|
|
void dispose() {
|
|
_cache = null;
|
|
_closed = true;
|
|
_engine?.close();
|
|
_engine?.port.close();
|
|
}
|
|
}
|
|
|
|
mixin class _JSEngineApi {
|
|
CookieJarSql? _cookieJar;
|
|
|
|
final _documents = <int, DocumentWrapper>{};
|
|
|
|
Object? handleHtmlCallback(Map<String, dynamic> data) {
|
|
switch (data["function"]) {
|
|
case "parse":
|
|
if (_documents.length > 8) {
|
|
var shouldDelete = _documents.keys.first;
|
|
Log.warning(
|
|
"JS Engine",
|
|
"Too many documents, deleting the oldest: $shouldDelete\n"
|
|
"Current documents: ${_documents.keys}",
|
|
);
|
|
_documents.remove(shouldDelete);
|
|
}
|
|
_documents[data["key"]] = DocumentWrapper.parse(data["data"]);
|
|
return null;
|
|
case "querySelector":
|
|
var key = data["key"];
|
|
return _documents[key]!.querySelector(data["query"]);
|
|
case "querySelectorAll":
|
|
var key = data["key"];
|
|
return _documents[key]!.querySelectorAll(data["query"]);
|
|
case "getText":
|
|
return _documents[data["doc"]]!.elementGetText(data["key"]);
|
|
case "getAttributes":
|
|
var res = _documents[data["doc"]]!.elementGetAttributes(data["key"]);
|
|
return res;
|
|
case "dom_querySelector":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementQuerySelector(data["key"], data["query"]);
|
|
case "dom_querySelectorAll":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementQuerySelectorAll(data["key"], data["query"]);
|
|
case "getChildren":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementGetChildren(data["key"]);
|
|
case "getNodes":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementGetNodes(data["key"]);
|
|
case "getInnerHTML":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementGetInnerHTML(data["key"]);
|
|
case "getParent":
|
|
var doc = _documents[data["doc"]]!;
|
|
return doc.elementGetParent(data["key"]);
|
|
case "node_text":
|
|
return _documents[data["doc"]]!.nodeGetText(data["key"]);
|
|
case "node_type":
|
|
return _documents[data["doc"]]!.nodeType(data["key"]);
|
|
case "node_to_element":
|
|
return _documents[data["doc"]]!.nodeToElement(data["key"]);
|
|
case "dispose":
|
|
var docKey = data["key"];
|
|
_documents.remove(docKey);
|
|
return null;
|
|
case "getClassNames":
|
|
return _documents[data["doc"]]!.getClassNames(data["key"]);
|
|
case "getId":
|
|
return _documents[data["doc"]]!.getId(data["key"]);
|
|
case "getLocalName":
|
|
return _documents[data["doc"]]!.getLocalName(data["key"]);
|
|
case "getElementById":
|
|
return _documents[data["key"]]!.getElementById(data["id"]);
|
|
case "getPreviousSibling":
|
|
return _documents[data["doc"]]!.getPreviousSibling(data["key"]);
|
|
case "getNextSibling":
|
|
return _documents[data["doc"]]!.getNextSibling(data["key"]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
dynamic handleCookieCallback(Map<String, dynamic> data) {
|
|
switch (data["function"]) {
|
|
case "set":
|
|
_cookieJar!.saveFromResponse(
|
|
Uri.parse(data["url"]),
|
|
(data["cookies"] as List).map((e) {
|
|
var c = Cookie(e["name"], e["value"]);
|
|
if (e['domain'] != null) {
|
|
c.domain = e['domain'];
|
|
}
|
|
return c;
|
|
}).toList());
|
|
return null;
|
|
case "get":
|
|
var cookies = _cookieJar!.loadForRequest(Uri.parse(data["url"]));
|
|
return cookies
|
|
.map((e) => {
|
|
"name": e.name,
|
|
"value": e.value,
|
|
"domain": e.domain,
|
|
"path": e.path,
|
|
"expires": e.expires,
|
|
"max-age": e.maxAge,
|
|
"secure": e.secure,
|
|
"httpOnly": e.httpOnly,
|
|
"session": e.expires == null,
|
|
})
|
|
.toList();
|
|
case "delete":
|
|
clearCookies([data["url"]]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void clearCookies(List<String> domains) async {
|
|
for (var domain in domains) {
|
|
var uri = Uri.tryParse(domain);
|
|
if (uri == null) continue;
|
|
_cookieJar!.deleteUri(uri);
|
|
}
|
|
}
|
|
|
|
Object? _convert(Map<String, dynamic> data) {
|
|
String type = data["type"];
|
|
var value = data["value"];
|
|
bool isEncode = data["isEncode"];
|
|
try {
|
|
switch (type) {
|
|
case "utf8":
|
|
return isEncode ? utf8.encode(value) : utf8.decode(value);
|
|
case "base64":
|
|
return isEncode ? base64Encode(value) : base64Decode(value);
|
|
case "md5":
|
|
return Uint8List.fromList(md5.convert(value).bytes);
|
|
case "sha1":
|
|
return Uint8List.fromList(sha1.convert(value).bytes);
|
|
case "sha256":
|
|
return Uint8List.fromList(sha256.convert(value).bytes);
|
|
case "sha512":
|
|
return Uint8List.fromList(sha512.convert(value).bytes);
|
|
case "hmac":
|
|
var key = data["key"];
|
|
var hash = data["hash"];
|
|
var hmac = Hmac(
|
|
switch (hash) {
|
|
"md5" => md5,
|
|
"sha1" => sha1,
|
|
"sha256" => sha256,
|
|
"sha512" => sha512,
|
|
_ => throw "Unsupported hash: $hash"
|
|
},
|
|
key);
|
|
if (data['isString'] == true) {
|
|
return hmac.convert(value).toString();
|
|
} else {
|
|
return Uint8List.fromList(hmac.convert(value).bytes);
|
|
}
|
|
case "aes-ecb":
|
|
if (!isEncode) {
|
|
var key = data["key"];
|
|
var cipher = ECBBlockCipher(AESEngine());
|
|
cipher.init(
|
|
false,
|
|
KeyParameter(key),
|
|
);
|
|
var offset = 0;
|
|
var result = Uint8List(value.length);
|
|
while (offset < value.length) {
|
|
offset += cipher.processBlock(
|
|
value,
|
|
offset,
|
|
result,
|
|
offset,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
return null;
|
|
case "aes-cbc":
|
|
if (!isEncode) {
|
|
var key = data["key"];
|
|
var iv = data["iv"];
|
|
var cipher = CBCBlockCipher(AESEngine());
|
|
cipher.init(false, ParametersWithIV(KeyParameter(key), iv));
|
|
var offset = 0;
|
|
var result = Uint8List(value.length);
|
|
while (offset < value.length) {
|
|
offset += cipher.processBlock(
|
|
value,
|
|
offset,
|
|
result,
|
|
offset,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
return null;
|
|
case "aes-cfb":
|
|
if (!isEncode) {
|
|
var key = data["key"];
|
|
var blockSize = data["blockSize"];
|
|
var cipher = CFBBlockCipher(AESEngine(), blockSize);
|
|
cipher.init(false, KeyParameter(key));
|
|
var offset = 0;
|
|
var result = Uint8List(value.length);
|
|
while (offset < value.length) {
|
|
offset += cipher.processBlock(
|
|
value,
|
|
offset,
|
|
result,
|
|
offset,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
return null;
|
|
case "aes-ofb":
|
|
if (!isEncode) {
|
|
var key = data["key"];
|
|
var blockSize = data["blockSize"];
|
|
var cipher = OFBBlockCipher(AESEngine(), blockSize);
|
|
cipher.init(false, KeyParameter(key));
|
|
var offset = 0;
|
|
var result = Uint8List(value.length);
|
|
while (offset < value.length) {
|
|
offset += cipher.processBlock(
|
|
value,
|
|
offset,
|
|
result,
|
|
offset,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
return null;
|
|
case "rsa":
|
|
if (!isEncode) {
|
|
var key = data["key"];
|
|
final cipher = PKCS1Encoding(RSAEngine());
|
|
cipher.init(false,
|
|
PrivateKeyParameter<RSAPrivateKey>(_parsePrivateKey(key)));
|
|
return _processInBlocks(cipher, value);
|
|
}
|
|
return null;
|
|
default:
|
|
return value;
|
|
}
|
|
} catch (e, s) {
|
|
Log.error("JS Engine", "Failed to convert $type: $e", s);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
RSAPrivateKey _parsePrivateKey(String privateKeyString) {
|
|
List<int> privateKeyDER = base64Decode(privateKeyString);
|
|
var asn1Parser = ASN1Parser(privateKeyDER as Uint8List);
|
|
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
|
final privateKey = topLevelSeq.elements![2];
|
|
|
|
asn1Parser = ASN1Parser(privateKey.valueBytes!);
|
|
final pkSeq = asn1Parser.nextObject() as ASN1Sequence;
|
|
|
|
final modulus = pkSeq.elements![1] as ASN1Integer;
|
|
final privateExponent = pkSeq.elements![3] as ASN1Integer;
|
|
final p = pkSeq.elements![4] as ASN1Integer;
|
|
final q = pkSeq.elements![5] as ASN1Integer;
|
|
|
|
return RSAPrivateKey(
|
|
modulus.integer!, privateExponent.integer!, p.integer!, q.integer!);
|
|
}
|
|
|
|
Uint8List _processInBlocks(AsymmetricBlockCipher engine, Uint8List input) {
|
|
final numBlocks = input.length ~/ engine.inputBlockSize +
|
|
((input.length % engine.inputBlockSize != 0) ? 1 : 0);
|
|
|
|
final output = Uint8List(numBlocks * engine.outputBlockSize);
|
|
|
|
var inputOffset = 0;
|
|
var outputOffset = 0;
|
|
while (inputOffset < input.length) {
|
|
final chunkSize = (inputOffset + engine.inputBlockSize <= input.length)
|
|
? engine.inputBlockSize
|
|
: input.length - inputOffset;
|
|
|
|
outputOffset += engine.processBlock(
|
|
input, inputOffset, chunkSize, output, outputOffset);
|
|
|
|
inputOffset += chunkSize;
|
|
}
|
|
|
|
return (output.length == outputOffset)
|
|
? output
|
|
: output.sublist(0, outputOffset);
|
|
}
|
|
|
|
num _random(num min, num max, String type) {
|
|
if (type == "double") {
|
|
return min + (max - min) * math.Random().nextDouble();
|
|
}
|
|
return (min + (max - min) * math.Random().nextDouble()).toInt();
|
|
}
|
|
}
|
|
|
|
class DocumentWrapper {
|
|
final dom.Document doc;
|
|
|
|
DocumentWrapper.parse(String doc) : doc = html.parse(doc);
|
|
|
|
var elements = <dom.Element>[];
|
|
|
|
var nodes = <dom.Node>[];
|
|
|
|
int? querySelector(String query) {
|
|
var element = doc.querySelector(query);
|
|
if (element == null) return null;
|
|
elements.add(element);
|
|
return elements.length - 1;
|
|
}
|
|
|
|
List<int> querySelectorAll(String query) {
|
|
var res = doc.querySelectorAll(query);
|
|
var keys = <int>[];
|
|
for (var element in res) {
|
|
elements.add(element);
|
|
keys.add(elements.length - 1);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
String? elementGetText(int key) {
|
|
return elements[key].text;
|
|
}
|
|
|
|
Map<String, String> elementGetAttributes(int key) {
|
|
return elements[key].attributes.map(
|
|
(key, value) => MapEntry(
|
|
key.toString(),
|
|
value,
|
|
),
|
|
);
|
|
}
|
|
|
|
String? elementGetInnerHTML(int key) {
|
|
return elements[key].innerHtml;
|
|
}
|
|
|
|
int? elementGetParent(int key) {
|
|
var res = elements[key].parent;
|
|
if (res == null) return null;
|
|
elements.add(res);
|
|
return elements.length - 1;
|
|
}
|
|
|
|
int? elementQuerySelector(int key, String query) {
|
|
var res = elements[key].querySelector(query);
|
|
if (res == null) return null;
|
|
elements.add(res);
|
|
return elements.length - 1;
|
|
}
|
|
|
|
List<int> elementQuerySelectorAll(int key, String query) {
|
|
var res = elements[key].querySelectorAll(query);
|
|
var keys = <int>[];
|
|
for (var element in res) {
|
|
elements.add(element);
|
|
keys.add(elements.length - 1);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
List<int> elementGetChildren(int key) {
|
|
var res = elements[key].children;
|
|
var keys = <int>[];
|
|
for (var element in res) {
|
|
elements.add(element);
|
|
keys.add(elements.length - 1);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
List<int> elementGetNodes(int key) {
|
|
var res = elements[key].nodes;
|
|
var keys = <int>[];
|
|
for (var node in res) {
|
|
nodes.add(node);
|
|
keys.add(nodes.length - 1);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
String? nodeGetText(int key) {
|
|
return nodes[key].text;
|
|
}
|
|
|
|
String nodeType(int key) {
|
|
return switch (nodes[key].nodeType) {
|
|
dom.Node.ELEMENT_NODE => "element",
|
|
dom.Node.TEXT_NODE => "text",
|
|
dom.Node.COMMENT_NODE => "comment",
|
|
dom.Node.DOCUMENT_NODE => "document",
|
|
_ => "unknown"
|
|
};
|
|
}
|
|
|
|
int? nodeToElement(int key) {
|
|
if (nodes[key] is dom.Element) {
|
|
elements.add(nodes[key] as dom.Element);
|
|
return elements.length - 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<String> getClassNames(int key) {
|
|
return (elements[key]).classes.toList();
|
|
}
|
|
|
|
String? getId(int key) {
|
|
return (elements[key]).id;
|
|
}
|
|
|
|
String? getLocalName(int key) {
|
|
return (elements[key]).localName;
|
|
}
|
|
|
|
int? getElementById(String id) {
|
|
var element = doc.getElementById(id);
|
|
if (element == null) return null;
|
|
elements.add(element);
|
|
return elements.length - 1;
|
|
}
|
|
|
|
int? getPreviousSibling(int key) {
|
|
var res = elements[key].previousElementSibling;
|
|
if (res == null) return null;
|
|
elements.add(res);
|
|
return elements.length - 1;
|
|
}
|
|
|
|
int? getNextSibling(int key) {
|
|
var res = elements[key].nextElementSibling;
|
|
if (res == null) return null;
|
|
elements.add(res);
|
|
return elements.length - 1;
|
|
}
|
|
}
|