218 lines
7.9 KiB
Dart
218 lines
7.9 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:html/dom.dart' as dom;
|
|
import 'package:html/parser.dart' as html;
|
|
|
|
/// An opt-in mail attachment, separate from persistent structured logs.
|
|
/// Keeps only course/exam evidence, never the surrounding account/login page.
|
|
class SchoolPageDiagnostic {
|
|
SchoolPageDiagnostic({DateTime Function()? clock})
|
|
: clock = clock ?? DateTime.now;
|
|
static final instance = SchoolPageDiagnostic();
|
|
static const maxBytes = 2 * 1024 * 1024;
|
|
static const marker = '<!doctype html><!-- TPSchedule school diagnostic -->';
|
|
final DateTime Function() clock;
|
|
Uint8List? _bytes;
|
|
DateTime? _at;
|
|
Timer? _expiry;
|
|
|
|
void clear() {
|
|
_expiry?.cancel();
|
|
_expiry = null;
|
|
_bytes = null;
|
|
_at = null;
|
|
}
|
|
|
|
Uint8List? export() {
|
|
final at = _at;
|
|
if (at == null ||
|
|
clock().difference(at).isNegative ||
|
|
clock().difference(at) >= const Duration(minutes: 10)) {
|
|
clear();
|
|
}
|
|
return _bytes == null ? null : Uint8List.fromList(_bytes!);
|
|
}
|
|
|
|
void remember(String content, Uri uri) {
|
|
clear();
|
|
try {
|
|
final undergraduate =
|
|
uri.scheme == 'https' &&
|
|
uri.port == 443 &&
|
|
uri.host == 'eams.uestc.edu.cn' &&
|
|
uri.path.startsWith('/eams/');
|
|
final graduate =
|
|
uri.host == 'yjsjy.uestc.edu.cn' &&
|
|
(uri.scheme == 'https' && uri.port == 443 ||
|
|
uri.scheme == 'http' && uri.port == 80) &&
|
|
(uri.path == '/pyxx' || uri.path.startsWith('/pyxx/'));
|
|
if ((!undergraduate && !graduate) ||
|
|
uri.userInfo.isNotEmpty ||
|
|
utf8.encode(content).length > maxBytes) {
|
|
return;
|
|
}
|
|
final doc = html.parse(content);
|
|
if (doc.querySelector('input[type="password"]') != null) return;
|
|
final tables = doc
|
|
.querySelectorAll('table')
|
|
.where(
|
|
(t) => graduate
|
|
? const ['tbl', 'sample-table-1'].contains(t.id)
|
|
: t.id == 'manualArrangeCourseTable' ||
|
|
t.querySelector('tr')?.text.contains('课程序号') == true,
|
|
)
|
|
.toList();
|
|
if (tables.isEmpty) return;
|
|
final graduateTerms = <dom.Element>[];
|
|
if (graduate) {
|
|
for (final table in tables) {
|
|
final id = table.id == 'tbl' ? 'drpxq' : 'xqbh';
|
|
final original = doc.querySelector('select#$id');
|
|
if (original == null) continue;
|
|
final select = dom.Element.tag('select')..id = id;
|
|
for (final originalOption in original.querySelectorAll('option')) {
|
|
final option = dom.Element.tag('option')
|
|
..text = originalOption.text;
|
|
final value = originalOption.attributes['value'];
|
|
if (value != null && RegExp(r'^\d{1,6}$').hasMatch(value)) {
|
|
option.attributes['value'] = value;
|
|
}
|
|
if (originalOption.attributes.containsKey('selected')) {
|
|
option.attributes['selected'] = '';
|
|
}
|
|
select.children.add(option);
|
|
}
|
|
graduateTerms.add(select);
|
|
}
|
|
}
|
|
final fields = <String, Set<String>>{};
|
|
void field(String key, String value) {
|
|
if (const ['semester.id', 'examType.id'].contains(key) &&
|
|
RegExp(r'^\d{1,6}$').hasMatch(value)) {
|
|
fields.putIfAbsent(key, () => {}).add(value);
|
|
}
|
|
}
|
|
|
|
for (final input in doc.querySelectorAll('input')) {
|
|
field(input.attributes['name'] ?? '', input.attributes['value'] ?? '');
|
|
}
|
|
for (final node in doc.querySelectorAll('script,input[name="params"]')) {
|
|
final value = node.localName == 'input'
|
|
? node.attributes['value'] ?? ''
|
|
: node.text;
|
|
for (final m in RegExp(
|
|
r'(semester\.id|examType\.id)=(\d{1,6})(?=[&"\x27\s]|$)',
|
|
).allMatches(value)) {
|
|
field(m[1]!, m[2]!);
|
|
}
|
|
}
|
|
final script = doc
|
|
.querySelectorAll('script')
|
|
.map((s) => s.text)
|
|
.join('\n');
|
|
// Only retain the literal course statements our parser reads. All other
|
|
// scripts, URL-bearing attributes and form/account fields are omitted.
|
|
final statements = undergraduate ? _courseStatements(script) : '';
|
|
for (final table in tables) {
|
|
for (final node in table.querySelectorAll(
|
|
'script,style,link,iframe,object,embed,form,input,textarea,select,button,img,svg,video,audio,source,meta,base',
|
|
)) {
|
|
node.remove();
|
|
}
|
|
for (final node in [table, ...table.querySelectorAll('*')]) {
|
|
final attrs = Map<String, String>.from(node.attributes);
|
|
node.attributes.clear();
|
|
final id = attrs['id'];
|
|
if (node.localName == 'table' &&
|
|
(id == 'manualArrangeCourseTable' ||
|
|
graduate && const ['tbl', 'sample-table-1'].contains(id))) {
|
|
node.id = id!;
|
|
} else if (graduate &&
|
|
const ['td', 'th'].contains(node.localName) &&
|
|
id != null &&
|
|
RegExp(r'^t[0-7][13579]$').hasMatch(id)) {
|
|
node.id = id;
|
|
}
|
|
for (final key in ['rowspan', 'colspan']) {
|
|
final value = attrs[key];
|
|
if (value != null && RegExp(r'^\d{1,2}$').hasMatch(value)) {
|
|
node.attributes[key] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
final safe =
|
|
'$marker<html><head><meta charset="utf-8">'
|
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
|
|
'<title>TPSchedule school diagnostic</title></head><body>'
|
|
'${graduateTerms.map((t) => t.outerHtml).join()}'
|
|
'${fields.entries.expand((e) => e.value.map((v) => '<input type="hidden" name="${e.key}" value="$v">')).join()}'
|
|
'${tables.map((t) => t.outerHtml).join()}'
|
|
'<script type="application/x-tpschedule-course-data">${statements.replaceAll('<', r'\u003c')}</script></body></html>';
|
|
final bytes = Uint8List.fromList(utf8.encode(safe));
|
|
if (bytes.length > maxBytes) return;
|
|
_bytes = bytes;
|
|
_at = clock();
|
|
_expiry = Timer(const Duration(minutes: 10), clear);
|
|
} catch (_) {
|
|
clear(); // Diagnostic collection must not interfere with importing.
|
|
}
|
|
}
|
|
|
|
// Split outside quoted strings without validating the course arguments: a
|
|
// malformed argument/index is precisely what this attachment must preserve.
|
|
static String _courseStatements(String script) {
|
|
final out = <String>[];
|
|
var start = 0;
|
|
String? quote;
|
|
var lineComment = false, blockComment = false;
|
|
final allowed = RegExp(
|
|
r'^(?:var\s+)?(?:unitCount\s*=|activity\s*=\s*new\s+TaskActivity\b|new\s+TaskActivity\b|index\s*=|table\d+\.activities\s*\[)',
|
|
);
|
|
void keep(int end) {
|
|
final statement = script
|
|
.substring(start, end)
|
|
.replaceFirst(
|
|
RegExp(r'^(?:\s|//[^\r\n]*(?:\r?\n|$)|/\*[\s\S]*?\*/)*'),
|
|
'',
|
|
)
|
|
.trim();
|
|
if (allowed.hasMatch(statement)) out.add(statement);
|
|
start = end;
|
|
}
|
|
|
|
for (var i = 0; i < script.length; i++) {
|
|
final c = script[i];
|
|
final next = i + 1 < script.length ? script[i + 1] : '';
|
|
if (lineComment) {
|
|
if (c == '\n' || c == '\r') lineComment = false;
|
|
} else if (blockComment) {
|
|
if (c == '*' && next == '/') {
|
|
blockComment = false;
|
|
i++;
|
|
}
|
|
} else if (quote != null) {
|
|
if (c == '\\') {
|
|
i++;
|
|
} else if (c == quote) {
|
|
quote = null;
|
|
}
|
|
} else if (c == '/' && next == '/') {
|
|
lineComment = true;
|
|
i++;
|
|
} else if (c == '/' && next == '*') {
|
|
blockComment = true;
|
|
i++;
|
|
} else if (c == '"' || c == "'" || c == '`') {
|
|
quote = c;
|
|
} else if (c == ';') {
|
|
keep(i + 1);
|
|
}
|
|
}
|
|
if (start < script.length) keep(script.length);
|
|
return out.join('\n');
|
|
}
|
|
}
|