diff --git a/mobile/lib/features/school/undergraduate/navigation_policy.dart b/mobile/lib/features/school/undergraduate/navigation_policy.dart
new file mode 100644
index 0000000..6a2dc28
--- /dev/null
+++ b/mobile/lib/features/school/undergraduate/navigation_policy.dart
@@ -0,0 +1,22 @@
+import '../school_navigation_policy.dart';
+
+class UndergraduateNavigationPolicy {
+ static bool canNavigate(Uri uri, {required bool isMainFrame}) =>
+ uri.scheme == 'https' &&
+ uri.port == 443 &&
+ uri.userInfo.isEmpty &&
+ const {
+ 'eams.uestc.edu.cn',
+ 'idas.uestc.edu.cn',
+ 'eportal.uestc.edu.cn',
+ }.contains(uri.host) ||
+ !isMainFrame && uri.toString() == 'about:blank';
+ static bool isDataPage(Uri uri) =>
+ canNavigate(uri, isMainFrame: true) &&
+ uri.host == 'eams.uestc.edu.cn' &&
+ uri.path.startsWith('/eams/');
+ static bool isLogout(Uri uri) =>
+ isDataPage(uri) && uri.path == '/eams/logout.action';
+ static Uri? secureIdentityRedirect(Uri uri) =>
+ SchoolNavigationPolicy.secureIdentityRedirect(uri);
+}
diff --git a/mobile/lib/features/school/undergraduate/page_probe.dart b/mobile/lib/features/school/undergraduate/page_probe.dart
new file mode 100644
index 0000000..3e45166
--- /dev/null
+++ b/mobile/lib/features/school/undergraduate/page_probe.dart
@@ -0,0 +1,121 @@
+import 'dart:convert';
+
+import 'navigation_policy.dart';
+
+const undergraduateProbeBase = r'''(() => {
+ const capture=__CAPTURE__;
+ const topUrl=location.href;
+ const result={topUrl,authenticated:false,pages:[]};
+ const trusted=value=>{try{const u=new URL(value);return u.protocol==='https:' && u.hostname==='eams.uestc.edu.cn' &&
+ (!u.port || u.port==='443') && !u.username && !u.password && u.pathname.startsWith('/eams/');}catch(_){return false;}};
+ if(!trusted(topUrl) || document.querySelector('input[type="password"]'))return JSON.stringify(result);
+ const origin=new URL(topUrl).origin,seen=new Set();let total=0;
+ const headers=['课程序号','课程名称','考试日期','考试安排','考试地点','座位号','考试情况','考试类型','其它说明'];
+ const add=(kind,url,body)=>{
+ total+=new TextEncoder().encode(body).byteLength;
+ if(total>2*1024*1024)throw new Error('capture_limit');
+ result.pages.push({kind,url,html:body});
+ };
+ const visit=(doc,depth)=>{
+ if(!doc || seen.has(doc))return;
+ let url;try{url=doc.location.href;}catch(_){return;}
+ if(!trusted(url) || new URL(url).origin!==origin || doc.querySelector('input[type="password"]'))return;
+ if(depth>4 || seen.size>=16)throw new Error('capture_limit');seen.add(doc);
+ const timetable=doc.querySelector('#manualArrangeCourseTable');
+ const exams=Array.from(doc.querySelectorAll('table')).filter(table=>{
+ const row=table.querySelector('tr');if(!row)return false;
+ const values=Array.from(row.children).map(c=>c.textContent.trim());
+ return values.length===9 && headers.every((v,i)=>values[i]===v);
+ });
+ const logout=Array.from(doc.querySelectorAll('a[href]')).some(a=>{try{return new URL(a.getAttribute('href'),url).pathname==='/eams/logout.action';}catch(_){return false;}});
+ result.authenticated=result.authenticated || Boolean(logout || timetable || exams.length);
+ if(capture && (timetable || exams.length)){
+ const fields=Array.from(doc.querySelectorAll('input[name="semester.id"],input[name="examType.id"],input[name="params"]')).map(input=>{
+ const clone=input.cloneNode(true);clone.setAttribute('value',input.value);return clone.outerHTML;
+ }).join('');
+ const scripts=Array.from(doc.querySelectorAll('script')).filter(s=>/new\s+TaskActivity|\bunitCount\s*=|\bvar\s+params\s*=/.test(s.textContent)).map(s=>s.outerHTML).join('');
+ if(timetable){
+ const roster=Array.from(doc.querySelectorAll('table')).filter(table=>{
+ const row=table.querySelector('tr');return row && Array.from(row.children).some(c=>c.textContent.trim()==='课程序号') &&
+ Array.from(row.children).some(c=>c.textContent.trim()==='课程代码');
+ }).map(t=>t.outerHTML).join('');
+ add('timetable',url,fields+timetable.outerHTML+roster+scripts);
+ }
+ for(const table of exams)add('exams',url,'
'+table.outerHTML+scripts);
+ }
+ for(const frame of doc.querySelectorAll('iframe,frame')){
+ let child;try{child=frame.contentDocument;}catch(_){continue;}visit(child,depth+1);
+ }
+ };
+ try{visit(document,0);}catch(_){result.error='capture_limit';result.pages=[];}
+ return JSON.stringify(result);
+})()''';
+
+final undergraduateStatusProbeScript = undergraduateProbeBase.replaceAll(
+ '__CAPTURE__',
+ 'false',
+);
+final undergraduateCaptureScript = undergraduateProbeBase.replaceAll(
+ '__CAPTURE__',
+ 'true',
+);
+
+class UndergraduateFragment {
+ const UndergraduateFragment(this.kind, this.url, this.html);
+ final String kind, html;
+ final Uri url;
+}
+
+class UndergraduatePageEvidence {
+ const UndergraduatePageEvidence(this.authenticated, this.pages);
+ final bool authenticated;
+ final List pages;
+ static UndergraduatePageEvidence decode(Object raw, Uri current) {
+ try {
+ dynamic value = raw;
+ for (var i = 0; i < 2 && value is String; i++) {
+ value = jsonDecode(value);
+ }
+ if (value is! Map ||
+ value['topUrl'] != current.toString() ||
+ value['error'] != null ||
+ !UndergraduateNavigationPolicy.canNavigate(
+ current,
+ isMainFrame: true,
+ ))
+ throw const FormatException('页面证据无效');
+ final pages = value['pages'];
+ if (pages is! List || pages.length > 32)
+ throw const FormatException('页面片段过多');
+ var bytes = 0;
+ final result = [];
+ for (final page in pages) {
+ if (page is! Map ||
+ page['url'] is! String ||
+ page['html'] is! String ||
+ !const ['timetable', 'exams'].contains(page['kind']))
+ throw const FormatException('页面片段无效');
+ final uri = Uri.parse(page['url'] as String),
+ body = page['html'] as String;
+ bytes += utf8.encode(body).length;
+ if (bytes > 2 * 1024 * 1024 ||
+ !UndergraduateNavigationPolicy.isDataPage(current) ||
+ !UndergraduateNavigationPolicy.isDataPage(uri) ||
+ current.origin != uri.origin)
+ throw const FormatException('页面来源或大小无效');
+ result.add(UndergraduateFragment(page['kind'] as String, uri, body));
+ }
+ if (!UndergraduateNavigationPolicy.isDataPage(current) &&
+ (value['authenticated'] == true || result.isNotEmpty))
+ throw const FormatException('认证页面不能作为数据来源');
+ return UndergraduatePageEvidence(
+ value['authenticated'] == true,
+ List.unmodifiable(result),
+ );
+ } on FormatException {
+ rethrow;
+ } catch (_) {
+ throw const FormatException('学校页面读取未完成');
+ }
+ }
+}
diff --git a/mobile/test/school/undergraduate_page_probe_js_test.cjs b/mobile/test/school/undergraduate_page_probe_js_test.cjs
new file mode 100644
index 0000000..ba76ed3
--- /dev/null
+++ b/mobile/test/school/undergraduate_page_probe_js_test.cjs
@@ -0,0 +1,35 @@
+const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
+const source=fs.readFileSync(path.join(__dirname,'../../lib/features/school/undergraduate/page_probe.dart'),'utf8');
+const base=source.match(/undergraduateProbeBase = r'''([\s\S]*?)''';/)[1];
+const home='https://eams.uestc.edu.cn/eams/';
+function input(name,value='503') {
+ return {value,cloneNode(){return {stored:'523',setAttribute(k,v){this.stored=v;},get outerHTML(){return ``;}};}};
+}
+function doc(url=home,{password=false,timetable=false,exams=false,frames=[],big=false}={}) {
+ const table={get outerHTML(){if(big)return '汉'.repeat(800000);return '';}};
+ const exam={querySelector(){return {children:['课程序号','课程名称','考试日期','考试安排','考试地点','座位号','考试情况','考试类型','其它说明'].map(textContent=>({textContent}))};},outerHTML:''};
+ return {location:new URL(url),querySelector(s){
+ if(s==='input[type="password"]')return password?{}:null;
+ if(s==='#manualArrangeCourseTable')return timetable?table:null;
+ if(s==='#stdExamTableForm')return exams?{}:null;return null;
+ },querySelectorAll(s){
+ if(s==='iframe,frame')return frames;
+ if(s==='a[href]')return [{getAttribute(){return '/eams/logout.action';}}];
+ if(s==='table')return exams?[exam]:[];
+ if(s==='script')return [{textContent:'var unitCount = 12; activity = new TaskActivity("data");',outerHTML:''}];
+ if(s==='input[name="semester.id"],input[name="examType.id"],input[name="params"]')return [input('semester.id'),input('examType.id','1')];
+ return [];
+ }};
+}
+function probe(document,capture=true){return JSON.parse(vm.runInNewContext(base.replaceAll('__CAPTURE__',String(capture)),{URL,TextEncoder,document,location:document.location}));}
+assert.equal(probe(doc(home,{timetable:true}),false).pages.length,0,'status never captures timetable');
+assert.equal(probe(doc(home,{timetable:true}),false).authenticated,true);
+const captured=probe(doc(home,{timetable:true}));
+assert.equal(captured.pages.length,1);assert.match(captured.pages[0].html,/value="503"/);assert.doesNotMatch(captured.pages[0].html,/value="523"/);
+assert.equal(probe(doc(home,{password:true,timetable:true})).pages.length,0);
+assert.equal(probe(doc('https://idas.uestc.edu.cn/authserver/login',{timetable:true})).pages.length,0);
+assert.equal(probe(doc(home,{frames:[{contentDocument:doc(home,{timetable:true})},{contentDocument:doc('https://other.test/eams/',{timetable:true})}]})).pages.length,1);
+assert.equal(probe(doc(home,{timetable:true,big:true})).error,'capture_limit');
+assert.equal(probe(doc(home,{frames:Array.from({length:17},()=>({contentDocument:doc(home,{timetable:true})}))})).error,'capture_limit');
+assert.equal(probe(doc(home,{exams:true})).pages[0].kind,'exams');
+console.log('Undergraduate probe: status-only, actual values, same-origin frames, passwords and aggregate limits passed.');
diff --git a/mobile/test/school/undergraduate_page_probe_test.dart b/mobile/test/school/undergraduate_page_probe_test.dart
new file mode 100644
index 0000000..620c36b
--- /dev/null
+++ b/mobile/test/school/undergraduate_page_probe_test.dart
@@ -0,0 +1,96 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/features/school/undergraduate/navigation_policy.dart';
+import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
+
+void main() {
+ final uri = Uri.parse('https://eams.uestc.edu.cn/eams/');
+ test(
+ 'navigation and capture distinguish official data from authentication',
+ () {
+ for (final host in [
+ 'eams.uestc.edu.cn',
+ 'idas.uestc.edu.cn',
+ 'eportal.uestc.edu.cn',
+ ]) {
+ expect(
+ UndergraduateNavigationPolicy.canNavigate(
+ Uri.https(host, '/'),
+ isMainFrame: true,
+ ),
+ isTrue,
+ );
+ }
+ for (final value in [
+ 'https://eams.uestc.edu.cn.attacker.test/eams/',
+ 'https://eams.uestc.edu.cn:444/eams/',
+ 'https://user@eams.uestc.edu.cn/eams/',
+ 'http://eams.uestc.edu.cn/eams/',
+ ]) {
+ expect(
+ UndergraduateNavigationPolicy.canNavigate(
+ Uri.parse(value),
+ isMainFrame: true,
+ ),
+ isFalse,
+ );
+ }
+ expect(UndergraduateNavigationPolicy.isDataPage(uri), isTrue);
+ expect(
+ UndergraduateNavigationPolicy.isDataPage(
+ Uri.parse('https://idas.uestc.edu.cn/authserver/login'),
+ ),
+ isFalse,
+ );
+ expect(
+ UndergraduateNavigationPolicy.secureIdentityRedirect(
+ Uri.parse('http://idas.uestc.edu.cn/authserver/login?service=abc'),
+ ).toString(),
+ 'https://idas.uestc.edu.cn/authserver/login?service=abc',
+ );
+ },
+ );
+ test(
+ 'page evidence rejects changed URLs cross origins and aggregate oversize',
+ () {
+ Map value() => {
+ 'topUrl': uri.toString(),
+ 'authenticated': true,
+ 'pages': [
+ {'kind': 'timetable', 'url': uri.toString(), 'html': 'table'},
+ ],
+ };
+ expect(
+ UndergraduatePageEvidence.decode(jsonEncode(value()), uri).pages,
+ hasLength(1),
+ );
+ expect(
+ () => UndergraduatePageEvidence.decode({
+ ...value(),
+ 'topUrl': 'https://other.test',
+ }, uri),
+ throwsFormatException,
+ );
+ final cross = value();
+ (cross['pages'] as List).single['url'] = 'https://idas.uestc.edu.cn/';
+ expect(
+ () => UndergraduatePageEvidence.decode(cross, uri),
+ throwsFormatException,
+ );
+ final large = value();
+ (large['pages'] as List).single['html'] = '汉' * 800000;
+ expect(
+ () => UndergraduatePageEvidence.decode(large, uri),
+ throwsFormatException,
+ );
+ expect(
+ () => UndergraduatePageEvidence.decode({
+ ...value(),
+ 'error': 'capture_limit',
+ }, uri),
+ throwsFormatException,
+ );
+ },
+ );
+}