fix: follow undergraduate login links and capture live history URLs

This commit is contained in:
Codex
2026-10-15 17:17:54 +08:00
parent 79d268fd5e
commit defe7fd50e
14 changed files with 213 additions and 32 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# TPSchedule Flutter工程
当前版本0.7.3+21,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
当前版本0.7.4+22,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
学校浏览器沿用宽视口及第三方Cookie配置。0.7.3新增本科已登录缺依赖页面的同会话完整页恢复,保留原学校地址与来源;失败或切页不覆盖旧页面和课表,真实账号仍需复测。见 `../docs/tpschedule-0.7.3-validation.md`。
0.7.4修复本科重复登录HTTP继续链接及恢复后History API导致的网址不同步,导入绑定实际文档地址并严格校验同一学校来源。完整页恢复保留,真实账号仍需复测,见 `../docs/tpschedule-0.7.4-validation.md`。
## 本科使用
@@ -82,6 +82,11 @@ public final class SchoolDocumentRenderInstrumentation extends Instrumentation {
require(!applied.get(), "changed_session_accepted");
JSONObject retained = evaluate("JSON.stringify({text:document.getElementById('probe').textContent})");
require("电子科技大学 • 课表 ✓".equals(retained.getString("text")), "failed_render_changed_dom");
JSONObject history = evaluate("history.replaceState({},'', '/eams/courseTableForStd.action?semester.id=523'); JSON.stringify({href:location.href})");
AtomicReference<String> historyUrl = new AtomicReference<>();
runOnMainSync(() -> historyUrl.set(view.getUrl()));
require("https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523".equals(history.getString("href")), "live_history_url_missing");
require(URL.equals(historyUrl.get()), "native_history_characterization_changed");
result.putString("stream", "SchoolDocument render: URL/origin/Unicode/scripts/CSS/session/document guards passed\n");
sendStatus(0,result);
finishCode = Activity.RESULT_OK;
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.3';
const appBuildNumber = 21;
const appVersion = '0.7.4';
const appBuildNumber = 22;
@@ -17,6 +17,23 @@ class UndergraduateNavigationPolicy {
uri.path.startsWith('/eams/');
static bool isLogout(Uri uri) =>
isDataPage(uri) && uri.path == '/eams/logout.action';
static Uri? secureIdentityRedirect(Uri uri) =>
SchoolNavigationPolicy.secureIdentityRedirect(uri);
static Uri? secureIdentityRedirect(Uri uri) {
final identity = SchoolNavigationPolicy.secureIdentityRedirect(uri);
if (identity != null) return identity;
// Legacy duplicate-login links use HTTP even within the HTTPS portal.
if (uri.scheme != 'http' ||
uri.host != 'eams.uestc.edu.cn' ||
uri.port != 80 ||
uri.userInfo.isNotEmpty ||
!(uri.path == '/eams' || uri.path.startsWith('/eams/'))) {
return null;
}
return Uri(
scheme: 'https',
host: uri.host,
path: uri.path,
query: uri.hasQuery ? uri.query : null,
fragment: uri.hasFragment ? uri.fragment : null,
);
}
}
@@ -67,9 +67,10 @@ class UndergraduateFragment {
}
class UndergraduatePageEvidence {
const UndergraduatePageEvidence(this.authenticated, this.pages);
const UndergraduatePageEvidence(this.authenticated, this.pages, this.url);
final bool authenticated;
final List<UndergraduateFragment> pages;
final Uri url;
static UndergraduatePageEvidence decode(Object raw, Uri current) {
try {
dynamic value = raw;
@@ -116,6 +117,7 @@ class UndergraduatePageEvidence {
return UndergraduatePageEvidence(
value['authenticated'] == true,
List.unmodifiable(result),
current,
);
} on FormatException {
rethrow;
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:webview_flutter/webview_flutter.dart';
@@ -318,13 +319,35 @@ class UndergraduateSession extends ChangeNotifier {
});
}
Future<UndergraduatePageEvidence> _evidence(Uri uri, bool capture) async =>
UndergraduatePageEvidence.decode(
await web.runJavaScriptReturningResult(
capture ? undergraduateCaptureScript : undergraduateStatusProbeScript,
),
uri,
Future<UndergraduatePageEvidence> _evidence(Uri uri, bool capture) async {
// loadDataWithBaseURL can leave native getUrl at the original address after
// the school's History API changes location. Bind capture to the live URL,
// still requiring the native and live documents to share the school origin.
dynamic location = await web.runJavaScriptReturningResult('location.href');
if (location is String && location.startsWith('"')) {
location = jsonDecode(location);
}
final live = location is String ? Uri.tryParse(location) : null;
if (live == null ||
!UndergraduateNavigationPolicy.isDataPage(uri) ||
!UndergraduateNavigationPolicy.isDataPage(live) ||
uri.origin != live.origin) {
throw const FormatException('页面来源无效,请重新打开学校课表。');
}
if (live != uri) {
DiagnosticLog.instance.add(
'school_document_url',
fields: {'school': 'undergraduate', 'code': 'history_state'},
);
}
return UndergraduatePageEvidence.decode(
await web.runJavaScriptReturningResult(
capture ? undergraduateCaptureScript : undergraduateStatusProbeScript,
),
live,
);
}
Future<void> _loaded(String url) async {
DiagnosticLog.instance.navigation('undergraduate', 'finished', url);
final g = _generation, d = _document, key = '$_generation:$_document';
@@ -361,18 +384,20 @@ 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();
},
);
final recovery = uri != status.url
? FullPageRecoveryResult.unchanged
: 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) {
@@ -397,7 +422,7 @@ class UndergraduateSession extends ChangeNotifier {
_emit();
return;
}
if (_expected == null || uri.toString() != _expected.toString()) {
if (_expected == null || status.url.toString() != _expected.toString()) {
await _requestExam(g);
return;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.7.3+21
version: 0.7.4+22
environment:
sdk: ^3.13.5
dependencies:
@@ -6,6 +6,51 @@ import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
void main() {
final uri = Uri.parse('https://eams.uestc.edu.cn/eams/');
test(
'legacy HTTP continuation upgrade is limited to the exact school system',
() {
expect(
UndergraduateNavigationPolicy.secureIdentityRedirect(
Uri.parse(
'http://eams.uestc.edu.cn/eams/home.action?login=1&next=%2Feams%2F#continue',
),
).toString(),
'https://eams.uestc.edu.cn/eams/home.action?login=1&next=%2Feams%2F#continue',
);
for (final address in [
'http://eams.uestc.edu.cn.attacker.test/eams/home.action',
'http://user@eams.uestc.edu.cn/eams/home.action',
'http://eams.uestc.edu.cn:8080/eams/home.action',
'http://eams.uestc.edu.cn/other/action',
]) {
expect(
UndergraduateNavigationPolicy.secureIdentityRedirect(
Uri.parse(address),
),
isNull,
);
}
},
);
test(
'decoder still requires captured URL to match the live document URL',
() {
expect(
() => UndergraduatePageEvidence.decode({
'topUrl': 'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523',
'authenticated': true,
'pages': [
{
'kind': 'timetable',
'url': 'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523',
'html': 'table',
},
],
}, uri),
throwsFormatException,
);
},
);
test(
'navigation and capture distinguish official data from authentication',
() {
@@ -31,6 +31,59 @@ void main() {
session = UndergraduateSession();
});
tearDown(() => session.dispose());
test('live URL cannot cross origin or change again during capture', () async {
await session.openTimetable();
final content = File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync();
platform.web.javascript = (_) async => underEvidence(
'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523',
html: content,
);
for (final address in [
'https://other.test/eams/courseTableForStd.action',
'https://idas.uestc.edu.cn/authserver/login',
'http://eams.uestc.edu.cn/eams/courseTableForStd.action',
'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=503',
]) {
platform.web.documentUrl = address;
await session.readCurrentTimetable();
expect(session.pendingDraft, isNull);
expect(session.message, contains('未完成'));
}
});
test('visible timetable imports after same-document history changes native URL does not follow', () async {
await session.openTimetable();
platform.web.documentUrl = 'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523';
final content = File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync();
platform.web.javascript = (_) async => underEvidence(
'https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523',
html: content,
);
platform.delegate.finished(platform.web.url!);
await flushSchoolEvents();
expect(session.statusLabel, '已连接');
await session.readCurrentTimetable();
expect(session.pendingDraft!.courses, hasLength(18));
});
test(
'duplicate login continuation upgrades exact school HTTP to HTTPS',
() async {
await session.openTimetable();
final decision = await platform.delegate.navigation(
NavigationRequest(
url: 'http://eams.uestc.edu.cn/eams/home.action?login=1',
isMainFrame: true,
),
);
expect(decision, NavigationDecision.prevent);
expect(
platform.web.loads.last,
'https://eams.uestc.edu.cn/eams/home.action?login=1',
);
expect(session.message, isNot(contains('未完成')));
},
);
test(
'page-finished during an old probe is retried on the redirected portal',
() async {
+4 -1
View File
@@ -44,6 +44,7 @@ class FakeSchoolWeb extends PlatformWebViewController {
FakeSchoolWeb(super.params) : super.implementation();
final loads = <String>[];
String? url;
String? documentUrl;
Future<Object> Function(String)? javascript;
Future<void> Function(Uri)? request;
@override
@@ -63,7 +64,9 @@ class FakeSchoolWeb extends PlatformWebViewController {
Future<String?> currentUrl() async => url;
@override
Future<Object> runJavaScriptReturningResult(String script) =>
javascript?.call(script) ?? Future.value(false);
script == 'location.href'
? Future.value(documentUrl ?? url ?? 'about:blank')
: javascript?.call(script) ?? Future.value(false);
@override
Future<void> clearCache() async {}
@override