fix: recover authenticated undergraduate full pages with session guards
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,14 @@ import 'timetable_adapter.dart';
|
||||
import 'exam_adapter.dart';
|
||||
import '../school_browser.dart';
|
||||
import '../school_browser_diagnostics.dart';
|
||||
import '../school_full_page_recovery.dart';
|
||||
|
||||
class UndergraduateSession extends ChangeNotifier {
|
||||
UndergraduateSession({this.configureBrowser = configureSchoolBrowser});
|
||||
UndergraduateSession({
|
||||
this.configureBrowser = configureSchoolBrowser,
|
||||
SchoolFullPageRecovery? pageRecovery,
|
||||
}) : _pageRecovery = pageRecovery ?? SchoolFullPageRecovery();
|
||||
final SchoolFullPageRecovery _pageRecovery;
|
||||
final SchoolBrowserConfigurator configureBrowser;
|
||||
Future<SchoolBrowserInfo>? _browserReady;
|
||||
String? browserVersion;
|
||||
@@ -28,6 +33,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_connected = false,
|
||||
_loading = false,
|
||||
_manualCapture = false,
|
||||
_recoveringPage = false,
|
||||
_failed = false,
|
||||
_complete = false;
|
||||
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
|
||||
@@ -44,7 +50,8 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
int get pageGeneration => _generation;
|
||||
bool get requestingExams => query != null;
|
||||
bool get canLogout => hasOpened || logoutFailed;
|
||||
bool get busy => _loading || _manualCapture || _itemTimeout != null;
|
||||
bool get busy =>
|
||||
_loading || _manualCapture || _recoveringPage || _itemTimeout != null;
|
||||
String get statusLabel => logoutFailed
|
||||
? '退出未完成'
|
||||
: _failed
|
||||
@@ -70,6 +77,8 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
|
||||
void _reset() {
|
||||
_generation++;
|
||||
_pageRecovery.reset();
|
||||
_recoveringPage = false;
|
||||
_cancelTimers();
|
||||
_parts.clear();
|
||||
_index = 0;
|
||||
@@ -123,6 +132,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (url) {
|
||||
_pageRecovery.invalidateDocument();
|
||||
DiagnosticLog.instance.navigation('undergraduate', 'started', url);
|
||||
// Within a batch, type-to-type loads retain their generation. Once
|
||||
// published, any new document invalidates the deferred save result.
|
||||
@@ -130,6 +140,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_reset();
|
||||
}
|
||||
_document++;
|
||||
_recoveringPage = false;
|
||||
_poll?.cancel();
|
||||
_probeKey = null;
|
||||
pendingDraft = null;
|
||||
@@ -350,6 +361,37 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
_connected = true;
|
||||
awaitingAuthentication = false;
|
||||
final recovery = await _pageRecovery.tryRestore(
|
||||
web,
|
||||
uri,
|
||||
authenticated: true,
|
||||
documentKey: '$g:$d',
|
||||
isCurrent: () => _valid(g) && d == _document,
|
||||
onStarted: () {
|
||||
_recoveringPage = true;
|
||||
message = '已登录,正在恢复完整教务页面…';
|
||||
_emit();
|
||||
},
|
||||
);
|
||||
if (!_valid(g) || d != _document) return;
|
||||
_recoveringPage = false;
|
||||
if (recovery == FullPageRecoveryResult.restored) {
|
||||
_loading = true;
|
||||
message = '完整页面已恢复,正在检查…';
|
||||
_schedulePoll(g, d, url);
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (recovery == FullPageRecoveryResult.cancelled) {
|
||||
message = '页面已变化,请等待加载完成或重新认证。';
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (recovery == FullPageRecoveryResult.failed) {
|
||||
message = '已登录,但完整教务页面暂未恢复,请重新认证后重试。';
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (!requestingExams) {
|
||||
message = manualMessage;
|
||||
_emit();
|
||||
@@ -438,7 +480,9 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> readCurrentTimetable() async {
|
||||
if (requestingExams || _manualCapture || !_active) return;
|
||||
if (requestingExams || _manualCapture || _recoveringPage || !_active) {
|
||||
return;
|
||||
}
|
||||
final g = _generation, d = _document;
|
||||
_manualCapture = true;
|
||||
_emit();
|
||||
@@ -535,6 +579,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_disposed = true;
|
||||
_active = false;
|
||||
_generation++;
|
||||
_pageRecovery.reset();
|
||||
_cancelTimers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user