296 lines
9.5 KiB
Dart
296 lines
9.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:html/parser.dart' as html;
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
|
|
|
import '../../services/diagnostic_log.dart';
|
|
import 'school_browser_diagnostics.dart';
|
|
|
|
class SchoolPageCandidate {
|
|
const SchoolPageCandidate(this.token, this.html);
|
|
final String token, html;
|
|
}
|
|
|
|
abstract interface class SchoolFullPageGateway {
|
|
bool supports(WebViewController web);
|
|
Future<SchoolPageCandidate> fetch(
|
|
WebViewController web,
|
|
Uri url,
|
|
String documentToken,
|
|
);
|
|
Future<bool> render(String token);
|
|
Future<void> discard(String token);
|
|
Future<void> invalidate(WebViewController web);
|
|
}
|
|
|
|
enum FullPageRecoveryResult { unchanged, restored, failed, cancelled }
|
|
|
|
class AndroidSchoolFullPageGateway implements SchoolFullPageGateway {
|
|
const AndroidSchoolFullPageGateway();
|
|
static const _channel = MethodChannel('com.tpschedule.app/school_document');
|
|
@override
|
|
bool supports(WebViewController web) =>
|
|
web.platform is AndroidWebViewController;
|
|
@override
|
|
Future<SchoolPageCandidate> fetch(
|
|
WebViewController web,
|
|
Uri url,
|
|
String documentToken,
|
|
) async {
|
|
final response = await _channel.invokeMapMethod<String, dynamic>('fetch', {
|
|
'viewId': (web.platform as AndroidWebViewController).webViewIdentifier,
|
|
'url': url.toString(),
|
|
'documentToken': documentToken,
|
|
});
|
|
final token = response?['token'], page = response?['html'];
|
|
if (token is! String ||
|
|
!RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(token) ||
|
|
page is! String) {
|
|
throw const FormatException('Invalid page response');
|
|
}
|
|
return SchoolPageCandidate(token, page);
|
|
}
|
|
|
|
@override
|
|
Future<bool> render(String token) async =>
|
|
await _channel.invokeMethod<bool>('render', {'token': token}) == true;
|
|
@override
|
|
Future<void> discard(String token) =>
|
|
_channel.invokeMethod<void>('discard', {'token': token});
|
|
@override
|
|
Future<void> invalidate(WebViewController web) => _channel.invokeMethod<void>(
|
|
'invalidate',
|
|
{'viewId': (web.platform as AndroidWebViewController).webViewIdentifier},
|
|
);
|
|
}
|
|
|
|
class SchoolFullPageRecovery {
|
|
SchoolFullPageRecovery({SchoolFullPageGateway? gateway})
|
|
: gateway = gateway ?? const AndroidSchoolFullPageGateway();
|
|
final SchoolFullPageGateway gateway;
|
|
final _attempted = <String>{};
|
|
int _epoch = 0;
|
|
WebViewController? _lastWeb;
|
|
void reset() {
|
|
invalidateDocument();
|
|
_attempted.clear();
|
|
}
|
|
|
|
void invalidateDocument() {
|
|
_epoch++;
|
|
final web = _lastWeb;
|
|
if (web != null && gateway.supports(web)) {
|
|
unawaited(gateway.invalidate(web).catchError((Object _) {}));
|
|
}
|
|
}
|
|
|
|
static bool canRestore(Uri uri) {
|
|
if (uri.scheme != 'https' ||
|
|
uri.host != 'eams.uestc.edu.cn' ||
|
|
uri.port != 443 ||
|
|
uri.userInfo.isNotEmpty) {
|
|
return false;
|
|
}
|
|
if (uri.queryParametersAll.keys.any(
|
|
(key) => const {
|
|
'x-requested-with',
|
|
'ticket',
|
|
'password',
|
|
'username',
|
|
'j_username',
|
|
'j_password',
|
|
'optype',
|
|
'operation',
|
|
'action',
|
|
'method',
|
|
}.contains(key.toLowerCase()),
|
|
)) {
|
|
return false;
|
|
}
|
|
return uri.path == '/eams' ||
|
|
uri.path == '/eams/' ||
|
|
RegExp(
|
|
r'^/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\.action$',
|
|
).hasMatch(uri.path);
|
|
}
|
|
|
|
Map? _pageState(Object raw) {
|
|
dynamic value = raw;
|
|
for (var i = 0; i < 2 && value is String; i++) {
|
|
value = jsonDecode(value);
|
|
}
|
|
return value is Map &&
|
|
value['scripts'] is int &&
|
|
value['scripts'] >= 0 &&
|
|
value['scripts'] <= 4 &&
|
|
value['styles'] == 0 &&
|
|
value['links'] == 0 &&
|
|
value['jquery'] == false &&
|
|
value['documentToken'] is String &&
|
|
RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(value['documentToken'])
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
bool _completePage(String source, Uri uri) {
|
|
if (source.length > 2 * 1024 * 1024 ||
|
|
utf8.encode(source).length > 2 * 1024 * 1024 ||
|
|
!RegExp(r'<head(?:\s|>)', caseSensitive: false).hasMatch(source)) {
|
|
return false;
|
|
}
|
|
final document = html.parse(source);
|
|
final head = document.head;
|
|
if (document.querySelector('input[type="password"]') != null ||
|
|
head == null ||
|
|
!head
|
|
.querySelectorAll('script[src]')
|
|
.any((e) => e.attributes['src']!.trim().isNotEmpty) ||
|
|
!head
|
|
.querySelectorAll('link[rel~="stylesheet"][href]')
|
|
.any((e) => e.attributes['href']!.trim().isNotEmpty)) {
|
|
return false;
|
|
}
|
|
if (head
|
|
.querySelectorAll('meta[http-equiv]')
|
|
.any((e) => e.attributes['http-equiv']!.toLowerCase() == 'refresh')) {
|
|
return false;
|
|
}
|
|
for (final base in head.querySelectorAll('base[href]')) {
|
|
if (uri.resolve(base.attributes['href']!).origin != uri.origin) {
|
|
return false;
|
|
}
|
|
}
|
|
return document.querySelector(
|
|
'#manualArrangeCourseTable, #stdExamTableForm',
|
|
) !=
|
|
null ||
|
|
document.querySelectorAll('a[href]').any((a) {
|
|
final target = uri.resolve(a.attributes['href']!);
|
|
return target.scheme == 'https' &&
|
|
target.host == uri.host &&
|
|
target.port == 443 &&
|
|
target.userInfo.isEmpty &&
|
|
target.path == '/eams/logout.action';
|
|
});
|
|
}
|
|
|
|
void _record(String phase, [String? code]) => DiagnosticLog.instance.add(
|
|
'school_full_page_restore',
|
|
fields: {'school': 'undergraduate', 'phase': phase, 'code': code},
|
|
);
|
|
|
|
Future<FullPageRecoveryResult> tryRestore(
|
|
WebViewController web,
|
|
Uri url, {
|
|
required bool authenticated,
|
|
required String documentKey,
|
|
required bool Function() isCurrent,
|
|
void Function()? onStarted,
|
|
}) async {
|
|
if (!authenticated ||
|
|
!canRestore(url) ||
|
|
!gateway.supports(web) ||
|
|
_attempted.contains(documentKey)) {
|
|
return FullPageRecoveryResult.unchanged;
|
|
}
|
|
final epoch = _epoch;
|
|
_lastWeb = web;
|
|
bool valid() => isCurrent() && epoch == _epoch;
|
|
SchoolPageCandidate? candidate;
|
|
try {
|
|
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
|
return FullPageRecoveryResult.cancelled;
|
|
}
|
|
final nonce = List.generate(
|
|
4,
|
|
(_) => Random.secure()
|
|
.nextInt(0x100000000)
|
|
.toRadixString(16)
|
|
.padLeft(8, '0'),
|
|
).join();
|
|
final diagnostic = schoolPageDiagnosticsScript.replaceAll(
|
|
'__EXPECTED_URL__',
|
|
jsonEncode(url.toString()),
|
|
);
|
|
final raw = await web.runJavaScriptReturningResult('''(() => {
|
|
const state=JSON.parse($diagnostic);
|
|
if(!state)return 'null';
|
|
if(typeof window.__tpscheduleDocumentToken!=='string')Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'$nonce',configurable:true});
|
|
state.documentToken=window.__tpscheduleDocumentToken;
|
|
return JSON.stringify(state);
|
|
})()''');
|
|
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
|
return FullPageRecoveryResult.cancelled;
|
|
}
|
|
final state = _pageState(raw);
|
|
if (state == null) return FullPageRecoveryResult.unchanged;
|
|
if (_attempted.length >= 4) return FullPageRecoveryResult.failed;
|
|
_attempted.add(documentKey);
|
|
onStarted?.call();
|
|
if (!valid()) return FullPageRecoveryResult.cancelled;
|
|
_record('requested', 'missing_head');
|
|
candidate = await gateway
|
|
.fetch(web, url, state['documentToken'] as String)
|
|
.timeout(const Duration(seconds: 35));
|
|
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
|
_record('cancelled', 'document_changed');
|
|
return FullPageRecoveryResult.cancelled;
|
|
}
|
|
if (!_completePage(candidate.html, url)) {
|
|
_record('rejected', 'incomplete_head');
|
|
return FullPageRecoveryResult.failed;
|
|
}
|
|
if (!valid()) return FullPageRecoveryResult.cancelled;
|
|
final rendered = await gateway
|
|
.render(candidate.token)
|
|
.timeout(const Duration(seconds: 10));
|
|
_record(
|
|
rendered ? 'applied' : 'cancelled',
|
|
rendered ? null : 'document_changed',
|
|
);
|
|
return rendered
|
|
? FullPageRecoveryResult.restored
|
|
: FullPageRecoveryResult.cancelled;
|
|
} catch (error) {
|
|
if (!valid()) return FullPageRecoveryResult.cancelled;
|
|
const codes = {
|
|
'invalid_url',
|
|
'no_session',
|
|
'document_changed',
|
|
'unexpected_status',
|
|
'redirect_rejected',
|
|
'invalid_content_type',
|
|
'too_large',
|
|
'network_failed',
|
|
'tls_failed',
|
|
'invalid_cookie',
|
|
'cookie_scope',
|
|
'unsupported_encoding',
|
|
'unsafe_url',
|
|
'stale_document',
|
|
'invalid_response',
|
|
'unavailable',
|
|
};
|
|
final code = error is PlatformException && codes.contains(error.code)
|
|
? error.code
|
|
: error is TimeoutException
|
|
? 'timeout'
|
|
: 'capture_failed';
|
|
_record('failed', code);
|
|
return FullPageRecoveryResult.failed;
|
|
} finally {
|
|
if (candidate != null) {
|
|
try {
|
|
await gateway.discard(candidate.token);
|
|
} catch (_) {
|
|
/* No pending private page is persisted. */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|