Files
TPSchedule/mobile/lib/features/school/school_full_page_recovery.dart
T

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. */
}
}
}
}
}