fix: recover authenticated undergraduate full pages with session guards

This commit is contained in:
Codex
2026-10-15 16:39:27 +08:00
parent 83b0c7e177
commit 79d268fd5e
22 changed files with 1418 additions and 13 deletions
@@ -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();
}