cloudflare verification

This commit is contained in:
nyne
2024-10-16 10:55:57 +08:00
parent 96ae6755bd
commit d01d0b5ddb
14 changed files with 591 additions and 35 deletions

View File

@@ -22,6 +22,7 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart';

View File

@@ -16,6 +16,7 @@ class NetworkError extends StatelessWidget {
@override
Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message);
Widget body = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -41,7 +42,7 @@ class NetworkError extends StatelessWidget {
height: 8,
),
Text(
message,
cfe == null ? message : "Cloudflare verification required".tl,
textAlign: TextAlign.center,
maxLines: 3,
),
@@ -50,7 +51,17 @@ class NetworkError extends StatelessWidget {
height: 12,
),
if (retry != null)
FilledButton(onPressed: retry, child: Text('重试'.tl))
if (cfe != null)
FilledButton(
onPressed: () => passCloudflare(
CloudflareException.fromString(message)!, retry!),
child: Text('Verify'.tl),
)
else
FilledButton(
onPressed: retry,
child: Text('Retry'.tl),
),
],
),
);

View File

@@ -21,6 +21,7 @@ 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/cloudflare.dart';
import 'package:venera/network/cookie_jar.dart';
import 'comic_source/comic_source.dart';
@@ -67,8 +68,7 @@ class JsEngine with _JSEngineApi{
responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!;
_dio!.interceptors.add(CookieManagerSql(_cookieJar!));
// TODO: Cloudflare Interceptor
// _dio!.interceptors.add(CloudflareInterceptor());
_dio!.interceptors.add(CloudflareInterceptor());
_closed = false;
_engine = FlutterQjs();
_engine!.dispatch();

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -11,7 +12,10 @@ import 'foundation/app.dart';
import 'foundation/appdata.dart';
import 'init.dart';
void main() {
void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) {
return;
}
runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
await init();

177
lib/network/cloudflare.dart Normal file
View File

@@ -0,0 +1,177 @@
import 'dart:io' as io;
import 'package:dio/dio.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/pages/webview.dart';
import 'cookie_jar.dart';
class CloudflareException implements DioException {
final String url;
const CloudflareException(this.url);
@override
String toString() {
return "CloudflareException: $url";
}
static CloudflareException? fromString(String message) {
var match = RegExp(r"CloudflareException: (.+)").firstMatch(message);
if (match == null) return null;
return CloudflareException(match.group(1)!);
}
@override
DioException copyWith(
{RequestOptions? requestOptions,
Response<dynamic>? response,
DioExceptionType? type,
Object? error,
StackTrace? stackTrace,
String? message}) {
return this;
}
@override
Object? get error => this;
@override
String? get message => toString();
@override
RequestOptions get requestOptions => RequestOptions();
@override
Response? get response => null;
@override
StackTrace get stackTrace => StackTrace.empty;
@override
DioExceptionType get type => DioExceptionType.badResponse;
}
class CloudflareInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if(options.headers['cookie'].toString().contains('cf_clearance')) {
options.headers['user-agent'] = appdata.implicitData['ua'] ?? webUA;
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 403) {
handler.next(_check(err.response!) ?? err);
} else {
handler.next(err);
}
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (response.statusCode == 403) {
var err = _check(response);
if (err != null) {
handler.reject(err);
return;
}
}
handler.next(response);
}
CloudflareException? _check(Response response) {
if (response.headers['cf-mitigated']?.firstOrNull == "challenge") {
return CloudflareException(response.requestOptions.uri.toString());
}
return null;
}
}
void passCloudflare(CloudflareException e, void Function() onFinished) async {
var url = e.url;
var uri = Uri.parse(url);
void saveCookies(Map<String, String> cookies) {
var domain = uri.host;
var splits = domain.split('.');
if (splits.length > 1) {
domain = ".${splits[splits.length - 2]}.${splits[splits.length - 1]}";
}
SingleInstanceCookieJar.instance!.saveFromResponse(
uri,
List<io.Cookie>.generate(cookies.length, (index) {
var cookie = io.Cookie(
cookies.keys.elementAt(index), cookies.values.elementAt(index));
cookie.domain = domain;
return cookie;
}),
);
}
if (App.isDesktop && (await DesktopWebview.isAvailable())) {
var webview = DesktopWebview(
initialUrl: url,
onTitleChange: (title, controller) async {
var res = await controller.evaluateJavascript(
"document.head.innerHTML.includes('#challenge-success-text')");
if (res == 'false') {
var ua = controller.userAgent;
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookiesMap = await controller.getCookies(url);
if(cookiesMap['cf_clearance'] == null) {
return;
}
saveCookies(cookiesMap);
controller.close();
onFinished();
}
},
);
webview.open();
} else if (App.isMobile) {
await App.rootContext.to(
() => AppWebview(
initialUrl: url,
singlePage: true,
onTitleChange: (title, controller) async {
var res = await controller.platform.evaluateJavascript(
source:
"document.head.innerHTML.includes('#challenge-success-text')");
if (res == false) {
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookiesMap = await controller.getCookies(url) ?? {};
if(cookiesMap['cf_clearance'] == null) {
return;
}
saveCookies(cookiesMap);
App.rootPop();
}
},
onStarted: (controller) async {
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookiesMap = await controller.getCookies(url) ?? {};
saveCookies(cookiesMap);
},
),
);
onFinished();
} else {
App.rootContext.showMessage(message: "Unsupported device");
}
}

View File

@@ -326,9 +326,6 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
@override
ValueListenable<bool> get canPopNotifier => canPop;
/*
flutter >=3.24.0 api
@override
void onPopInvokedWithResult(bool didPop, result) {
if (currentPage != -1) {
@@ -346,15 +343,4 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
});
}
}
*/
// flutter <3.24.0 api
@override
PopInvokedCallback? get onPopInvoked => (bool didPop) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
};
}

282
lib/pages/webview.dart Normal file
View File

@@ -0,0 +1,282 @@
import 'dart:async';
import 'dart:convert';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
export 'package:flutter_inappwebview/flutter_inappwebview.dart' show WebUri, URLRequest;
extension WebviewExtension on InAppWebViewController{
Future<Map<String, String>?> getCookies(String url) async{
if(url.contains("https://")){
url.replaceAll("https://", "");
}
if(url[url.length-1] == '/'){
url = url.substring(0, url.length-1);
}
CookieManager cookieManager = CookieManager.instance();
final cookies = await cookieManager.getCookies(url: WebUri(url));
Map<String, String> res = {};
for(var cookie in cookies){
res[cookie.name] = cookie.value;
}
return res;
}
Future<String?> getUA() async{
var res = await evaluateJavascript(source: "navigator.userAgent");
if(res is String){
if(res[0] == "'" || res[0] == "\"") {
res = res.substring(1, res.length-1);
}
}
return res is String ? res : null;
}
}
class AppWebview extends StatefulWidget {
const AppWebview({required this.initialUrl, this.onTitleChange,
this.onNavigation, this.singlePage = false, this.onStarted, super.key});
final String initialUrl;
final void Function(String title, InAppWebViewController controller)? onTitleChange;
final bool Function(String url)? onNavigation;
final void Function(InAppWebViewController controller)? onStarted;
final bool singlePage;
@override
State<AppWebview> createState() => _AppWebviewState();
}
class _AppWebviewState extends State<AppWebview> {
InAppWebViewController? controller;
String title = "Webview";
double _progress = 0;
@override
Widget build(BuildContext context) {
final actions = [
Tooltip(
message: "More",
child: IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: (){
showMenu(context: context, position: RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
0,
MediaQuery.of(context).size.width,
0
), items: [
PopupMenuItem(
child: Text("Open in browser".tl),
onTap: () async => launchUrlString((await controller?.getUrl())!.path),
),
PopupMenuItem(
child: Text("Copy link".tl),
onTap: () async => Clipboard.setData(ClipboardData(text: (await controller?.getUrl())!.path)),
),
PopupMenuItem(
child: Text("Reload".tl),
onTap: () => controller?.reload(),
),
]);
},
),
)
];
Widget body = InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.initialUrl)),
onTitleChanged: (c, t){
if(mounted){
setState(() {
title = t ?? "Webview";
});
}
widget.onTitleChange?.call(title, controller!);
},
shouldOverrideUrlLoading: (c, r) async {
var res = widget.onNavigation?.call(r.request.url?.toString() ?? "") ?? false;
if(res) {
return NavigationActionPolicy.CANCEL;
} else {
return NavigationActionPolicy.ALLOW;
}
},
onWebViewCreated: (c){
controller = c;
widget.onStarted?.call(c);
},
onProgressChanged: (c, p){
if(mounted){
setState(() {
_progress = p / 100;
});
}
},
);
body = Stack(
children: [
Positioned.fill(child: body),
if(_progress < 1.0)
const Positioned.fill(child: Center(
child: CircularProgressIndicator()))
],
);
return Scaffold(
appBar: Appbar(
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,),
actions: actions,
),
body: body
);
}
}
class DesktopWebview {
static Future<bool> isAvailable() => WebviewWindow.isWebviewAvailable();
final String initialUrl;
final void Function(String title, DesktopWebview controller)? onTitleChange;
final void Function(String url, DesktopWebview webview)? onNavigation;
final void Function(DesktopWebview controller)? onStarted;
final void Function()? onClose;
DesktopWebview({
required this.initialUrl,
this.onTitleChange,
this.onNavigation,
this.onStarted,
this.onClose
});
Webview? _webview;
String? _ua;
String? title;
void onMessage(String message) {
var json = jsonDecode(message);
if(json is Map){
if(json["id"] == "document_created"){
title = json["data"]["title"];
_ua = json["data"]["ua"];
onTitleChange?.call(title!, this);
}
}
}
String? get userAgent => _ua;
Timer? timer;
void _runTimer() {
timer ??= Timer.periodic(const Duration(seconds: 2), (t) async {
const js = '''
function collect() {
if(document.readyState === 'loading') {
return '';
}
let data = {
id: "document_created",
data: {
title: document.title,
url: location.href,
ua: navigator.userAgent
}
};
return data;
}
collect();
''';
if(_webview != null) {
onMessage(await evaluateJavascript(js) ?? '');
}
});
}
void open() async {
_webview = await WebviewWindow.create(configuration: CreateConfiguration(
useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview",
proxy: AppDio.proxy,
));
_webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) => onNavigation?.call(s, this));
_webview!.launch(initialUrl, triggerOnUrlRequestEvent: false);
_runTimer();
_webview!.onClose.then((value) {
_webview = null;
timer?.cancel();
timer = null;
onClose?.call();
});
Future.delayed(const Duration(milliseconds: 200), () {
onStarted?.call(this);
});
}
Future<String?> evaluateJavascript(String source) {
return _webview!.evaluateJavaScript(source);
}
Future<Map<String, String>> getCookies(String url) async{
var allCookies = await _webview!.getAllCookies();
var res = <String, String>{};
for(var c in allCookies) {
if(_cookieMatch(url, c.domain)){
res[_removeCode0(c.name)] = _removeCode0(c.value);
}
}
return res;
}
String _removeCode0(String s) {
var codeUints = List<int>.from(s.codeUnits);
codeUints.removeWhere((e) => e == 0);
return String.fromCharCodes(codeUints);
}
bool _cookieMatch(String url, String domain) {
domain = _removeCode0(domain);
var host = Uri.parse(url).host;
var acceptedHost = _getAcceptedDomains(host);
return acceptedHost.contains(domain.removeAllBlank);
}
List<String> _getAcceptedDomains(String host) {
var acceptedDomains = <String>[host];
var hostParts = host.split(".");
for (var i = 0; i < hostParts.length - 1; i++) {
acceptedDomains.add(".${hostParts.sublist(i).join(".")}");
}
return acceptedDomains;
}
void close() {
_webview?.close();
_webview = null;
}
}