Files
TPSchedule/mobile/lib/services/school_page_diagnostic.dart
T

178 lines
6.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
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 {
if (uri.scheme != 'https' ||
uri.host != 'eams.uestc.edu.cn' ||
uri.port != 443 ||
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
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) =>
t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
)
.toList();
if (tables.isEmpty) return;
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 = _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();
if (attrs['id'] == 'manualArrangeCourseTable') {
node.id = 'manualArrangeCourseTable';
}
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>'
'${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');
}
}