fix: harden school imports and add optional feedback diagnostics
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
|
||||
class DiagnosticLog {
|
||||
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
|
||||
static final instance = DiagnosticLog();
|
||||
static const maxBytes = 256 * 1024;
|
||||
static const _keys = {
|
||||
'school',
|
||||
'phase',
|
||||
'page',
|
||||
'count',
|
||||
'term',
|
||||
'type',
|
||||
'status',
|
||||
'code',
|
||||
'version',
|
||||
'result',
|
||||
};
|
||||
final DateTime Function() clock;
|
||||
final _entries = <Map<String, dynamic>>[];
|
||||
File? _file;
|
||||
Future<void> _writes = Future.value();
|
||||
Timer? _timer;
|
||||
|
||||
Map<String, Object> _safe(Map fields) => {
|
||||
for (final e in fields.entries.take(6))
|
||||
if (_keys.contains(e.key) &&
|
||||
(e.value is bool ||
|
||||
e.value is int ||
|
||||
e.value is String &&
|
||||
RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value)))
|
||||
e.key as String: e.value as Object,
|
||||
};
|
||||
void _trim() {
|
||||
final now = clock().toUtc();
|
||||
_entries.removeWhere((e) {
|
||||
final at = DateTime.tryParse(e['at'] as String? ?? '');
|
||||
return at == null ||
|
||||
at.isAfter(now) ||
|
||||
now.difference(at) > const Duration(minutes: 10);
|
||||
});
|
||||
if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500);
|
||||
}
|
||||
|
||||
void add(String event, {Map<String, Object?> fields = const {}}) {
|
||||
if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return;
|
||||
_trim();
|
||||
final safe = _safe(fields);
|
||||
if (_entries.isNotEmpty) {
|
||||
final last = _entries.last;
|
||||
final at = DateTime.tryParse(last['at'] as String? ?? '');
|
||||
final previous = Map<String, dynamic>.from(last)
|
||||
..remove('at')
|
||||
..remove('event');
|
||||
if (last['event'] == event &&
|
||||
jsonEncode(previous) == jsonEncode(safe) &&
|
||||
at != null &&
|
||||
clock().toUtc().difference(at) < const Duration(seconds: 3)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_entries.add({
|
||||
'at': clock().toUtc().toIso8601String(),
|
||||
'event': event,
|
||||
...safe,
|
||||
});
|
||||
_trim();
|
||||
unawaited(flush());
|
||||
}
|
||||
|
||||
void navigation(String school, String phase, String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final page = uri == null
|
||||
? 'unknown'
|
||||
: uri.host == 'idas.uestc.edu.cn'
|
||||
? 'identity'
|
||||
: uri.host == 'yjsjy.uestc.edu.cn'
|
||||
? uri.path.contains('xskbcx')
|
||||
? 'timetable'
|
||||
: uri.path.contains('kskcxx')
|
||||
? 'exams'
|
||||
: 'graduate_portal'
|
||||
: uri.host == 'eams.uestc.edu.cn'
|
||||
? uri.path.contains('stdExamTable')
|
||||
? 'exams'
|
||||
: uri.path.contains('courseTable')
|
||||
? 'timetable'
|
||||
: 'undergraduate_portal'
|
||||
: 'external';
|
||||
add('navigation_$phase', fields: {'school': school, 'page': page});
|
||||
}
|
||||
|
||||
Future<void> attach(Directory directory) async {
|
||||
final file = File('${directory.path}/diagnostics-recent.json');
|
||||
try {
|
||||
if (await file.exists() && await file.length() <= maxBytes) {
|
||||
final saved = jsonDecode(await file.readAsString());
|
||||
if (saved is List) {
|
||||
final restored = <Map<String, dynamic>>[];
|
||||
for (final row in saved.take(500)) {
|
||||
if (row is Map &&
|
||||
row['at'] is String &&
|
||||
row['event'] is String &&
|
||||
RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) {
|
||||
restored.add({
|
||||
'at': row['at'],
|
||||
'event': row['event'],
|
||||
..._safe(row),
|
||||
});
|
||||
}
|
||||
}
|
||||
_entries.insertAll(0, restored);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* Diagnostics must never block startup or feedback. */
|
||||
}
|
||||
_file = file;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(minutes: 1),
|
||||
(_) => unawaited(flush()),
|
||||
);
|
||||
await flush();
|
||||
}
|
||||
|
||||
Future<void> flush() {
|
||||
_trim();
|
||||
final file = _file;
|
||||
if (file == null) return Future.value();
|
||||
final text = jsonEncode(_entries);
|
||||
_writes = _writes.then((_) async {
|
||||
try {
|
||||
await file.writeAsString(text, flush: true);
|
||||
} catch (_) {}
|
||||
});
|
||||
return _writes;
|
||||
}
|
||||
|
||||
Future<Uint8List> export() async {
|
||||
_trim();
|
||||
await flush();
|
||||
final header =
|
||||
'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n'
|
||||
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
|
||||
return Uint8List.fromList(
|
||||
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() => _timer?.cancel();
|
||||
}
|
||||
Reference in New Issue
Block a user