fix: import unpublished exam rooms and add opt-in school HTML feedback
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# TPSchedule
|
# TPSchedule
|
||||||
|
|
||||||
当前Android版本0.7.4+22:修复本科重复登录“继续”链接被拦截,以及完整页面显示课表后导入报“页面证据无效”。保留0.7.3完整页恢复和来源校验,详见[0.7.4验证记录](docs/tpschedule-0.7.4-validation.md)。
|
当前Android版本0.7.5+23:兼容本科考试考场/座位未发布时的合并单元格,并提供可选本科导入HTML反馈附件。课表缺失字段问题增加具体诊断,仍需新的页面证据定位。详见[0.7.5验证记录](docs/tpschedule-0.7.5-validation.md)。
|
||||||
|
|
||||||
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
||||||
|
|
||||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.4+22,Android 7.0 及以上。
|
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.5+23,Android 7.0 及以上。
|
||||||
|
|
||||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Android 首版验证记录
|
# Android 首版验证记录
|
||||||
|
|
||||||
当前本科登录与导入修复版为0.7.4+22,验证见 [0.7.4记录](tpschedule-0.7.4-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
当前版本为0.7.5+23,兼容本科考试未发布考场,并新增可选HTML反馈附件,验证见 [0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
||||||
|
|
||||||
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
|
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# 学校接入状态 · TPSchedule 0.7.4
|
# 学校接入状态 · TPSchedule 0.7.5
|
||||||
|
|
||||||
|
0.7.5兼容本科考场/座位合并为“考试情况尚未发布”的记录,新增默认关闭的HTML邮件排查附件及具体课表字段错误码。0.7.4两例课表失败仍需实际页面才能定位,不宣称已解决,见[0.7.5记录](tpschedule-0.7.5-validation.md)。反馈服务器需同步更新,代理请求体至少24MiB。
|
||||||
|
|
||||||
0.7.4将本科重复登录的准确学校HTTP继续链接升级为HTTPS,并解决完整页恢复后History API改变实际文档地址、原生历史地址未同步导致的“页面证据无效”。现按实际文档地址抓取且保留同源及精确校验,见[0.7.4记录](tpschedule-0.7.4-validation.md)。
|
0.7.4将本科重复登录的准确学校HTTP继续链接升级为HTTPS,并解决完整页恢复后History API改变实际文档地址、原生历史地址未同步导致的“页面证据无效”。现按实际文档地址抓取且保留同源及精确校验,见[0.7.4记录](tpschedule-0.7.4-validation.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# TPSchedule 0.7.5+23:本科排考未发布兼容与HTML反馈排查
|
||||||
|
|
||||||
|
2026-10-16,F:/program/app。用户提供三份0.7.4+22日志与截图,均为本科流程,浏览器Chromium138.0.7204.179。前两例显示完整课表但点击导入时报“教师、教室或课程序号缺失”;第三例有日期和考试时间,但考场/座位显示“考试情况尚未发布”,读取报“考试记录字段不完整”。本记录不包含原始学生HTML、账号或日志正文。
|
||||||
|
|
||||||
|
## 定位与范围
|
||||||
|
|
||||||
|
考试解析器原先以9个物理单元格判断完整性。截图提示考场、座位被合并为一个尚未发布的单元格;构造该结构的回归用例在原实现中稳定失败。现仅接受位于考场列的colspan=2且文本为“考试情况尚未发布”的已知结构,恢复逻辑列映射,保留日期、时间、情况及考试类型;座位留空供界面显示尚未安排。其他异常合并、类型/学期不一致和损坏页面继续拒绝,旧数据不被部分结果覆盖。
|
||||||
|
|
||||||
|
课表的笼统错误由教师为空、教室为空或课程序号后缀格式不符三种分支共同产生。日志不含这些字段,测试同学无法提供新HTML,因此目前不能判断其中哪一种是实际原因,也不宣称修复了这两例课表导入。现区分固定原因码teacher_missing、room_missing、course_sequence_invalid及活动序号;日志中不写课程/教师/教室值。
|
||||||
|
|
||||||
|
## 用户确认的HTML反馈功能
|
||||||
|
|
||||||
|
用户选择通过反馈邮件发送,而不是新增本地导出。反馈问题页面在日志下新增默认关闭的“附带最近一次本科导入页面HTML”,须先选择日志并同意个人信息提示。加入开发的申请不携带HTML。
|
||||||
|
|
||||||
|
只保存来源校验通过后、解析前得到的本科课表/课程名单或考试表格片段,最大2MiB,单份内存缓存最多10分钟;退出登录、重新认证、开始新流程及会话销毁清除,不写本地日志/文件。离开学校浏览器返回反馈页面会保留尚未过期的缓存。提交时冻结附件及编号,失败重试字节不变;冻结副本的生命周期是当前反馈表单。过期或不存在时明确提示重新尝试导入或取消附件,保留填写内容。
|
||||||
|
|
||||||
|
片段移除账号页头、表单、事件/URL属性、网络资源、无关脚本和认证参数,只保留课程/考试表格、数字学期/类型以及课程声明与节次赋值;课程脚本使用不执行的类型,HTML带禁止网络/表单的CSP。扫描器不要求声明字段有效,保留null、未知节次表达式和注释后的声明,避免过滤掉错误证据。个人课表信息仍可能敏感,学生需主动同意。
|
||||||
|
|
||||||
|
客户端新增独立multipart字段diagnostic_html;服务端限制一份2MiB UTF-8 HTML,校验固定前缀、平衡结构、标签/属性白名单、精确CSP和惰性脚本类型。拒绝活动脚本、网络资源和事件属性,作为TPSchedule-school-page.html附在邮件中,不占媒体名额,参与原幂等摘要。旧客户端兼容。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- 考试合并单元格用例先失败、修复后通过,异常跨度仍拒绝。
|
||||||
|
- 课表错误分别报告固定原因;失败前捕获证据,退出后清除。HTML过期、来源限制和认证资料过滤通过。
|
||||||
|
- 原过滤器遗漏异常声明、未知节次及注释前缀的回归先失败;修正后导出仍保留原解析错误,正常合成课表重放为18条安排。
|
||||||
|
- 反馈默认关闭、提示确认、不可变附件及失败重试通过;服务端验证第5个附件、独立限额、无效MIME/UTF-8、超限、开发申请拒绝和幂等冲突。
|
||||||
|
- 使用Dart实际过滤器生成9387字节合成样本,Python服务接收并生成逐字节一致的邮件附件;样本中均为测试课程、教师和教室,不是学生原HTML。
|
||||||
|
- 完整Flutter测试281项通过;flutter analyze无问题;4项实际JavaScript采集脚本检查通过;Python反馈服务66项测试通过。
|
||||||
|
- 独立审查两项Important发现(错误证据丢失、服务端活动HTML)修正后复核,无未解决Critical/Important项。审查未代替真实账号测试。
|
||||||
|
|
||||||
|
## 部署与复测
|
||||||
|
|
||||||
|
反馈服务需更新同一容器代码,保留.env、SMTP密码、IDEMPOTENCY_SECRET和数据库卷。OpenResty及雷池请求体放行至少24MiB,原域名和端口链路保持。详见[0.7.5服务器更新说明](../server/feedback/html-diagnostic-update.md)。本次没有操作线上服务器或发送真实邮件。
|
||||||
|
|
||||||
|
覆盖安装后在校园网复测考试,核对待发布考场不会阻断导入;课表再尝试导入,在10分钟内返回反馈页勾选日志与HTML发送,取得具体缺失字段及页面后继续修复。真实手机、真实学校页面和线上SMTP仍需用户确认。
|
||||||
|
|
||||||
|
## 构建及签名
|
||||||
|
|
||||||
|
正式版与调试版构建通过。首次发布构建因生成的注册文件残留integration_test而失败,按完整发布构建流程重新生成配置后通过;该文件不入源码,未修改应用依赖或签名。正式APK的applicationId=com.tpschedule.app、versionName=0.7.5、versionCode=23,minSdk=24、targetSdk=36,60254031字节。
|
||||||
|
|
||||||
|
TPSchedule-0.7.5.apk与TPSchedule.apk相同,SHA256为A9BBBB1A64F14661A91D049D868309138DEBDD54D486363090B9AECEEC68CEF5。apksigner验证通过,与0.7.4证书逐字节相同,证书SHA256仍为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。
|
||||||
|
|
||||||
|
API36测试模拟器安装此正式包成功,冷启动返回Status=ok、LaunchState=COLD、TotalTime=1312ms,进程存在。Windows模拟器沿用测试专用的软件渲染启动参数,未修改手机安装包的默认渲染配置;这不是用户手机启动耗时结论。测试后关闭本任务创建的emulator-5556。
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# TPSchedule Flutter工程
|
# TPSchedule Flutter工程
|
||||||
|
|
||||||
当前版本0.7.4+22,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
当前版本0.7.5+23,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||||
|
|
||||||
0.7.4修复本科重复登录HTTP继续链接及恢复后History API导致的网址不同步,导入绑定实际文档地址并严格校验同一学校来源。完整页恢复保留,真实账号仍需复测,见 `../docs/tpschedule-0.7.4-validation.md`。
|
0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。
|
||||||
|
|
||||||
## 本科使用
|
## 本科使用
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
const appVersion = '0.7.4';
|
const appVersion = '0.7.5';
|
||||||
const appBuildNumber = 22;
|
const appBuildNumber = 23;
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ class HttpFeedbackSender implements FeedbackSender {
|
|||||||
throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交');
|
throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交');
|
||||||
}
|
}
|
||||||
if (response.statusCode == 422 && errorCode == 'validation_error') {
|
if (response.statusCode == 422 && errorCode == 'validation_error') {
|
||||||
if (submission.diagnosticLog != null &&
|
if ((submission.diagnosticLog != null ||
|
||||||
|
submission.diagnosticHtml != null) &&
|
||||||
result?['detail'] == 'Unexpected file field.') {
|
result?['detail'] == 'Unexpected file field.') {
|
||||||
throw const FeedbackException(
|
throw const FeedbackException(
|
||||||
'反馈服务器尚未更新日志附件协议,请先更新服务器,或取消附带日志后提交。填写内容已保留。',
|
'反馈服务器尚未更新日志或网页附件协议,请先更新服务器,或取消附带排查附件后提交。填写内容已保留。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交');
|
throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交');
|
||||||
@@ -206,6 +207,14 @@ class HttpFeedbackSender implements FeedbackSender {
|
|||||||
bytes.add(submission.diagnosticLog!);
|
bytes.add(submission.diagnosticLog!);
|
||||||
write('\r\n');
|
write('\r\n');
|
||||||
}
|
}
|
||||||
|
if (submission.diagnosticHtml != null) {
|
||||||
|
write(
|
||||||
|
'--$boundary\r\nContent-Disposition: form-data; name="diagnostic_html"; filename="TPSchedule-school-page.html"\r\n'
|
||||||
|
'Content-Type: text/html; charset=utf-8\r\n\r\n',
|
||||||
|
);
|
||||||
|
bytes.add(submission.diagnosticHtml!);
|
||||||
|
write('\r\n');
|
||||||
|
}
|
||||||
write('--$boundary--\r\n');
|
write('--$boundary--\r\n');
|
||||||
return bytes.takeBytes();
|
return bytes.takeBytes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../app/app_theme.dart';
|
import '../../app/app_theme.dart';
|
||||||
import '../../services/diagnostic_log.dart';
|
import '../../services/diagnostic_log.dart';
|
||||||
|
import '../../services/school_page_diagnostic.dart';
|
||||||
import 'feedback_submission.dart';
|
import 'feedback_submission.dart';
|
||||||
|
|
||||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||||
@@ -34,6 +35,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
var _success = false;
|
var _success = false;
|
||||||
var _uncertain = false;
|
var _uncertain = false;
|
||||||
var _includeLogs = false;
|
var _includeLogs = false;
|
||||||
|
var _includeHtml = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
FeedbackSubmission? _pendingSubmission;
|
FeedbackSubmission? _pendingSubmission;
|
||||||
|
|
||||||
@@ -107,6 +109,20 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
|
final html =
|
||||||
|
_pendingSubmission == null &&
|
||||||
|
_kind == FeedbackKind.problem &&
|
||||||
|
_includeHtml
|
||||||
|
? SchoolPageDiagnostic.instance.export()
|
||||||
|
: null;
|
||||||
|
if (_pendingSubmission == null &&
|
||||||
|
_kind == FeedbackKind.problem &&
|
||||||
|
_includeHtml &&
|
||||||
|
html == null) {
|
||||||
|
throw const FeedbackException(
|
||||||
|
'最近10分钟没有可用的本科导入页面。请返回学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
|
||||||
|
);
|
||||||
|
}
|
||||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||||
? FeedbackSubmission.problem(
|
? FeedbackSubmission.problem(
|
||||||
id: FeedbackSubmission.newId(),
|
id: FeedbackSubmission.newId(),
|
||||||
@@ -117,6 +133,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
diagnosticLog: _includeLogs
|
diagnosticLog: _includeLogs
|
||||||
? await DiagnosticLog.instance.export()
|
? await DiagnosticLog.instance.export()
|
||||||
: null,
|
: null,
|
||||||
|
diagnosticHtml: html,
|
||||||
)
|
)
|
||||||
: FeedbackSubmission.development(
|
: FeedbackSubmission.development(
|
||||||
id: FeedbackSubmission.newId(),
|
id: FeedbackSubmission.newId(),
|
||||||
@@ -128,6 +145,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
await widget.sender.send(_pendingSubmission!);
|
await widget.sender.send(_pendingSubmission!);
|
||||||
|
if (_pendingSubmission?.diagnosticHtml != null) {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
|
}
|
||||||
if (mounted) setState(() => _success = true);
|
if (mounted) setState(() => _success = true);
|
||||||
} on FeedbackDeliveryUncertain catch (error) {
|
} on FeedbackDeliveryUncertain catch (error) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -275,6 +295,58 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
: (value) {
|
: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_includeLogs = value ?? false;
|
_includeLogs = value ?? false;
|
||||||
|
if (!_includeLogs) _includeHtml = false;
|
||||||
|
_pendingSubmission = null;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
key: const Key('feedback-html'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('附带最近一次本科导入页面HTML'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
|
||||||
|
),
|
||||||
|
value: _includeHtml,
|
||||||
|
onChanged: _submitting || !_includeLogs
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
var accepted = value ?? false;
|
||||||
|
if (accepted) {
|
||||||
|
accepted =
|
||||||
|
await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('附带网页排查资料?'),
|
||||||
|
content: const Text(
|
||||||
|
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
child: const Text('同意附带'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_includeHtml = accepted;
|
||||||
_pendingSubmission = null;
|
_pendingSubmission = null;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class FeedbackSubmission {
|
|||||||
this.school,
|
this.school,
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
this.diagnosticLog,
|
this.diagnosticLog,
|
||||||
|
this.diagnosticHtml,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory FeedbackSubmission.problem({
|
factory FeedbackSubmission.problem({
|
||||||
@@ -112,6 +113,7 @@ class FeedbackSubmission {
|
|||||||
List<FeedbackAttachment> attachments = const [],
|
List<FeedbackAttachment> attachments = const [],
|
||||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||||
Uint8List? diagnosticLog,
|
Uint8List? diagnosticLog,
|
||||||
|
Uint8List? diagnosticHtml,
|
||||||
}) {
|
}) {
|
||||||
final text = message.trim();
|
final text = message.trim();
|
||||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||||
@@ -122,6 +124,10 @@ class FeedbackSubmission {
|
|||||||
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
||||||
throw const FeedbackException('日志附件不能超过256 KB');
|
throw const FeedbackException('日志附件不能超过256 KB');
|
||||||
}
|
}
|
||||||
|
if (diagnosticHtml != null &&
|
||||||
|
(diagnosticHtml.isEmpty || diagnosticHtml.length > 2 * 1024 * 1024)) {
|
||||||
|
throw const FeedbackException('网页排查附件不能超过2 MB');
|
||||||
|
}
|
||||||
return FeedbackSubmission._(
|
return FeedbackSubmission._(
|
||||||
id: id,
|
id: id,
|
||||||
kind: FeedbackKind.problem,
|
kind: FeedbackKind.problem,
|
||||||
@@ -131,6 +137,9 @@ class FeedbackSubmission {
|
|||||||
diagnosticLog: diagnosticLog == null
|
diagnosticLog: diagnosticLog == null
|
||||||
? null
|
? null
|
||||||
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
||||||
|
diagnosticHtml: diagnosticHtml == null
|
||||||
|
? null
|
||||||
|
: Uint8List.fromList(diagnosticHtml).asUnmodifiableView(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +170,7 @@ class FeedbackSubmission {
|
|||||||
final String? school;
|
final String? school;
|
||||||
final List<FeedbackAttachment> attachments;
|
final List<FeedbackAttachment> attachments;
|
||||||
final Uint8List? diagnosticLog;
|
final Uint8List? diagnosticLog;
|
||||||
|
final Uint8List? diagnosticHtml;
|
||||||
|
|
||||||
static String newId() {
|
static String newId() {
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
class UndergraduateActivityException extends FormatException {
|
||||||
|
const UndergraduateActivityException(
|
||||||
|
this.code,
|
||||||
|
String message,
|
||||||
|
this.activityNumber,
|
||||||
|
) : super(message);
|
||||||
|
final String code;
|
||||||
|
final int activityNumber;
|
||||||
|
}
|
||||||
|
|
||||||
class UndergraduateActivity {
|
class UndergraduateActivity {
|
||||||
UndergraduateActivity(
|
UndergraduateActivity(
|
||||||
this.teacher,
|
this.teacher,
|
||||||
@@ -30,8 +40,26 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
final m = matches[i], args = _strings(m[1]!);
|
final m = matches[i], args = _strings(m[1]!);
|
||||||
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
||||||
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
|
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
|
||||||
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty) {
|
if (suffix == null) {
|
||||||
throw const FormatException('教师、教室或课程序号缺失');
|
throw UndergraduateActivityException(
|
||||||
|
'course_sequence_invalid',
|
||||||
|
'课程序号格式无法识别,请附带网页反馈',
|
||||||
|
i + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args[1].trim().isEmpty) {
|
||||||
|
throw UndergraduateActivityException(
|
||||||
|
'teacher_missing',
|
||||||
|
'课程活动未提供教师信息,请附带网页反馈',
|
||||||
|
i + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args[5].trim().isEmpty) {
|
||||||
|
throw UndergraduateActivityException(
|
||||||
|
'room_missing',
|
||||||
|
'课程活动未提供教室信息,请附带网页反馈',
|
||||||
|
i + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final bitmap = args[6];
|
final bitmap = args[6];
|
||||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||||
|
|||||||
@@ -102,11 +102,26 @@ class UndergraduateExamAdapter {
|
|||||||
final explicitEmpty = RegExp(r'暂无考试|无考试安排|没有考试|暂无记录|没有符合条件的记录|没有数据|无数据')
|
final explicitEmpty = RegExp(r'暂无考试|无考试安排|没有考试|暂无记录|没有符合条件的记录|没有数据|无数据')
|
||||||
.hasMatch(table.text);
|
.hasMatch(table.text);
|
||||||
for (final row in table.querySelectorAll('tr').skip(1)) {
|
for (final row in table.querySelectorAll('tr').skip(1)) {
|
||||||
final cells = row.children
|
if (explicitEmpty && row.children.length == 1) continue;
|
||||||
.map((c) => c.text.replaceAll(RegExp(r'\s+'), ' ').trim())
|
final cells = <String>[];
|
||||||
.toList();
|
for (final cell in row.children) {
|
||||||
|
final value = cell.text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
final span = int.tryParse(cell.attributes['colspan'] ?? '1');
|
||||||
|
if ((cell.attributes['rowspan'] ?? '1') != '1') {
|
||||||
|
throw const FormatException('考试记录行合并结构无法识别');
|
||||||
|
}
|
||||||
|
if (span == 1) {
|
||||||
|
cells.add(value);
|
||||||
|
} else if (span == 2 &&
|
||||||
|
cells.length == 4 &&
|
||||||
|
RegExp(r'^[\[【]?考试情况尚未发布[\]】]?$').hasMatch(value)) {
|
||||||
|
// EAMS merges the room and seat columns until it publishes them.
|
||||||
|
cells.addAll([value, '']);
|
||||||
|
} else {
|
||||||
|
throw const FormatException('考试记录列合并结构无法识别');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cells.length != 9) {
|
if (cells.length != 9) {
|
||||||
if (explicitEmpty && cells.length == 1) continue;
|
|
||||||
throw const FormatException('考试记录字段不完整');
|
throw const FormatException('考试记录字段不完整');
|
||||||
}
|
}
|
||||||
final rowType = _type(cells[7]);
|
final rowType = _type(cells[7]);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import '../../../data/json_schedule_import.dart';
|
|||||||
import '../../../domain/exam_schedule.dart';
|
import '../../../domain/exam_schedule.dart';
|
||||||
import '../../../domain/undergraduate_exam_query.dart';
|
import '../../../domain/undergraduate_exam_query.dart';
|
||||||
import '../../../services/diagnostic_log.dart';
|
import '../../../services/diagnostic_log.dart';
|
||||||
|
import '../../../services/school_page_diagnostic.dart';
|
||||||
|
import 'activity_data.dart';
|
||||||
import 'navigation_policy.dart';
|
import 'navigation_policy.dart';
|
||||||
import 'page_probe.dart';
|
import 'page_probe.dart';
|
||||||
import 'timetable_adapter.dart';
|
import 'timetable_adapter.dart';
|
||||||
@@ -206,6 +208,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
Future<void> openTimetable() async {
|
Future<void> openTimetable() async {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
DiagnosticLog.instance.add(
|
DiagnosticLog.instance.add(
|
||||||
'login_opened',
|
'login_opened',
|
||||||
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
||||||
@@ -222,6 +225,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openExams(UndergraduateExamQuery value) async {
|
Future<void> openExams(UndergraduateExamQuery value) async {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
DiagnosticLog.instance.add(
|
DiagnosticLog.instance.add(
|
||||||
'login_opened',
|
'login_opened',
|
||||||
fields: {
|
fields: {
|
||||||
@@ -434,6 +438,10 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
|
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
|
||||||
|
SchoolPageDiagnostic.instance.remember(
|
||||||
|
pages.single.html,
|
||||||
|
pages.single.url,
|
||||||
|
);
|
||||||
final part = UndergraduateExamAdapter.extract(
|
final part = UndergraduateExamAdapter.extract(
|
||||||
pages.single.html,
|
pages.single.html,
|
||||||
expectedTerm: query!.term,
|
expectedTerm: query!.term,
|
||||||
@@ -519,6 +527,10 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
if (pages.length != 1) {
|
if (pages.length != 1) {
|
||||||
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
||||||
}
|
}
|
||||||
|
SchoolPageDiagnostic.instance.remember(
|
||||||
|
pages.single.html,
|
||||||
|
pages.single.url,
|
||||||
|
);
|
||||||
final draft = UndergraduateTimetableAdapter.extract(
|
final draft = UndergraduateTimetableAdapter.extract(
|
||||||
pages.single.html,
|
pages.single.html,
|
||||||
pages.single.url,
|
pages.single.url,
|
||||||
@@ -531,6 +543,18 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
_emit();
|
_emit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_valid(g) && d == _document) {
|
if (_valid(g) && d == _document) {
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'school_read_failed',
|
||||||
|
fields: {
|
||||||
|
'school': 'undergraduate',
|
||||||
|
'phase': 'timetable',
|
||||||
|
'code': error is UndergraduateActivityException
|
||||||
|
? error.code
|
||||||
|
: error.runtimeType.toString(),
|
||||||
|
if (error is UndergraduateActivityException)
|
||||||
|
'count': error.activityNumber,
|
||||||
|
},
|
||||||
|
);
|
||||||
message = '导入未完成:$error';
|
message = '导入未完成:$error';
|
||||||
_emit();
|
_emit();
|
||||||
}
|
}
|
||||||
@@ -554,6 +578,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reauthenticate() async {
|
Future<void> reauthenticate() async {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
await _logoutFuture;
|
await _logoutFuture;
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_reset();
|
_reset();
|
||||||
@@ -566,6 +591,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void invalidateAuthentication() {
|
void invalidateAuthentication() {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
_reset();
|
_reset();
|
||||||
_active = false;
|
_active = false;
|
||||||
hasOpened = false;
|
hasOpened = false;
|
||||||
@@ -601,6 +627,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_active = false;
|
_active = false;
|
||||||
_generation++;
|
_generation++;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.7.4+22
|
version: 0.7.5+23
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -33,6 +33,31 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test('HTML diagnostics use independent attachment field with an immutable payload', () async {
|
||||||
|
final transport = FakeFeedbackHttpTransport();
|
||||||
|
final html = Uint8List.fromList(
|
||||||
|
utf8.encode('<!doctype html><!-- TPSchedule school diagnostic -->'),
|
||||||
|
);
|
||||||
|
final submission = FeedbackSubmission.problem(
|
||||||
|
id: 'same-id',
|
||||||
|
message: '无法导入',
|
||||||
|
diagnosticHtml: html,
|
||||||
|
);
|
||||||
|
html.fillRange(0, html.length, 0);
|
||||||
|
await HttpFeedbackSender(
|
||||||
|
endpoint: 'https://example.com/v1/feedback',
|
||||||
|
transport: transport,
|
||||||
|
).send(submission);
|
||||||
|
final body = utf8.decode(transport.body!);
|
||||||
|
expect(
|
||||||
|
body,
|
||||||
|
contains(
|
||||||
|
'name="diagnostic_html"; filename="TPSchedule-school-page.html"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(body, contains('TPSchedule school diagnostic'));
|
||||||
|
expect(submission.attachments, isEmpty);
|
||||||
|
});
|
||||||
test(
|
test(
|
||||||
'old feedback service gets a specific diagnostic-upgrade hint',
|
'old feedback service gets a specific diagnostic-upgrade hint',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
|
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
|
||||||
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
|
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
|
||||||
|
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||||
|
|
||||||
class RecordingSender implements FeedbackSender {
|
class RecordingSender implements FeedbackSender {
|
||||||
final submissions = <FeedbackSubmission>[];
|
final submissions = <FeedbackSubmission>[];
|
||||||
@@ -35,6 +37,47 @@ Future<void> reveal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('HTML is opt in, confirmed, and stays identical on retry', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final store = SchoolPageDiagnostic.instance;
|
||||||
|
addTearDown(store.clear);
|
||||||
|
store.remember(
|
||||||
|
'<input name="semester.id" value="523"><table id="manualArrangeCourseTable"><tr><td>课程内容</td></tr></table>',
|
||||||
|
Uri.parse('https://eams.uestc.edu.cn/eams/courseTableForStd.action'),
|
||||||
|
);
|
||||||
|
final sender = RecordingSender()..fail = true;
|
||||||
|
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||||
|
await tester.enterText(find.byKey(const Key('feedback-message')), '不能导入');
|
||||||
|
await reveal(tester, find.byKey(const Key('feedback-html')));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CheckboxListTile>(find.byKey(const Key('feedback-html')))
|
||||||
|
.value,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('feedback-logs')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const Key('feedback-html')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('附带网页排查资料?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('同意附带'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||||
|
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
utf8.decode(sender.submissions.single.diagnosticHtml!),
|
||||||
|
contains('课程内容'),
|
||||||
|
);
|
||||||
|
store.clear();
|
||||||
|
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
identical(sender.submissions.first, sender.submissions.last),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
testWidgets('logs are opt in and included only when checked', (tester) async {
|
testWidgets('logs are opt in and included only when checked', (tester) async {
|
||||||
final sender = RecordingSender();
|
final sender = RecordingSender();
|
||||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||||
@@ -52,6 +95,7 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(sender.submissions.single.diagnosticLog, isNotNull);
|
expect(sender.submissions.single.diagnosticLog, isNotNull);
|
||||||
|
expect(sender.submissions.single.diagnosticHtml, isNull);
|
||||||
expect(sender.submissions.single.attachments, isEmpty);
|
expect(sender.submissions.single.attachments, isEmpty);
|
||||||
});
|
});
|
||||||
testWidgets('uncertain delivery retains original payload for status check', (
|
testWidgets('uncertain delivery retains original payload for status check', (
|
||||||
@@ -186,6 +230,8 @@ void main() {
|
|||||||
backwards: true,
|
backwards: true,
|
||||||
);
|
);
|
||||||
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
|
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
|
||||||
|
// Validation removes the error label after the tap, changing list height.
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ void main() {
|
|||||||
String fixture() =>
|
String fixture() =>
|
||||||
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync();
|
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync();
|
||||||
final term = SchoolTerm(503, school: SupportedSchool.uestcUndergraduate);
|
final term = SchoolTerm(503, school: SupportedSchool.uestcUndergraduate);
|
||||||
|
test('unpublished room and seat merged by school keep the dated exam', () {
|
||||||
|
final page = fixture().replaceFirst(
|
||||||
|
RegExp(r'<td>示例考场</td>\s*<td>1</td>'),
|
||||||
|
'<td colspan="2">[考试情况尚未发布]</td>',
|
||||||
|
);
|
||||||
|
expect(page, contains('colspan="2"'));
|
||||||
|
final result = UndergraduateExamAdapter.extract(page);
|
||||||
|
expect(result.records, hasLength(4));
|
||||||
|
final record = result.records.first;
|
||||||
|
expect(record.date, '2026-06-22');
|
||||||
|
expect(record.time, contains('14:00-16:00'));
|
||||||
|
expect(record.room, '[考试情况尚未发布]');
|
||||||
|
expect(record.seat, '');
|
||||||
|
expect(record.status, '正常');
|
||||||
|
expect(record.examTypeId, 1);
|
||||||
|
});
|
||||||
|
test('unexpected merged exam columns remain a failed import', () {
|
||||||
|
final page = fixture().replaceFirst(
|
||||||
|
'<td>示例考场</td>',
|
||||||
|
'<td colspan="3">[考试情况尚未发布]</td>',
|
||||||
|
);
|
||||||
|
expect(() => UndergraduateExamAdapter.extract(page), throwsFormatException);
|
||||||
|
});
|
||||||
test('actual exam result term wins over the outer default', () {
|
test('actual exam result term wins over the outer default', () {
|
||||||
final schedule = UndergraduateExamAdapter.extract(fixture());
|
final schedule = UndergraduateExamAdapter.extract(fixture());
|
||||||
expect(schedule.termId, '503');
|
expect(schedule.termId, '503');
|
||||||
@@ -68,17 +91,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
test('only recognized explicit empty exam tables are empty success', () {
|
test('only recognized explicit empty exam tables are empty success', () {
|
||||||
final empty =
|
final empty =
|
||||||
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${[
|
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${['课程序号', '课程名称', '考试日期', '考试安排', '考试地点', '座位号', '考试情况', '考试类型', '其它说明'].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
||||||
'课程序号',
|
|
||||||
'课程名称',
|
|
||||||
'考试日期',
|
|
||||||
'考试安排',
|
|
||||||
'考试地点',
|
|
||||||
'座位号',
|
|
||||||
'考试情况',
|
|
||||||
'考试类型',
|
|
||||||
'其它说明',
|
|
||||||
].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
|
||||||
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
|
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
|
||||||
expect(
|
expect(
|
||||||
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
|
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
|||||||
import 'package:hepan_everyday/domain/school_term.dart';
|
import 'package:hepan_everyday/domain/school_term.dart';
|
||||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
|
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
|
||||||
|
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||||
|
|
||||||
import '../support/fake_school_web.dart';
|
import '../support/fake_school_web.dart';
|
||||||
import 'undergraduate_session_test.dart' show underEvidence;
|
import 'undergraduate_session_test.dart' show underEvidence;
|
||||||
@@ -123,6 +124,7 @@ void main() {
|
|||||||
await finish();
|
await finish();
|
||||||
await tester.pump(const Duration(seconds: 46));
|
await tester.pump(const Duration(seconds: 46));
|
||||||
expect(session.message, contains('超时'));
|
expect(session.message, contains('超时'));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
expect(session.pendingExams, isNull);
|
expect(session.pendingExams, isNull);
|
||||||
});
|
});
|
||||||
test('cancelled batch ignores late page callbacks', () async {
|
test('cancelled batch ignores late page callbacks', () async {
|
||||||
@@ -199,5 +201,6 @@ void main() {
|
|||||||
await tester.pump(const Duration(seconds: 41));
|
await tester.pump(const Duration(seconds: 41));
|
||||||
expect(session.pendingExams, isNull);
|
expect(session.pendingExams, isNull);
|
||||||
expect(session.message, contains('超时'));
|
expect(session.message, contains('超时'));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||||
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
||||||
import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||||
|
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||||
|
|
||||||
import '../support/fake_school_web.dart';
|
import '../support/fake_school_web.dart';
|
||||||
|
|
||||||
@@ -31,6 +32,21 @@ void main() {
|
|||||||
session = UndergraduateSession();
|
session = UndergraduateSession();
|
||||||
});
|
});
|
||||||
tearDown(() => session.dispose());
|
tearDown(() => session.dispose());
|
||||||
|
test('failed import retains diagnostic evidence separately and logout removes it', () async {
|
||||||
|
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||||
|
await session.openTimetable();
|
||||||
|
final content = File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync()
|
||||||
|
.replaceFirst('"教师1"', '""');
|
||||||
|
platform.web.javascript = (_) async =>
|
||||||
|
underEvidence(platform.web.url!, html: content);
|
||||||
|
await session.readCurrentTimetable();
|
||||||
|
expect(session.pendingDraft, isNull);
|
||||||
|
expect(session.message, contains('教师'));
|
||||||
|
expect(SchoolPageDiagnostic.instance.export(), isNotNull);
|
||||||
|
session.invalidateAuthentication();
|
||||||
|
expect(SchoolPageDiagnostic.instance.export(), isNull);
|
||||||
|
});
|
||||||
test('live URL cannot cross origin or change again during capture', () async {
|
test('live URL cannot cross origin or change again during capture', () async {
|
||||||
await session.openTimetable();
|
await session.openTimetable();
|
||||||
final content = File('test/fixtures/school/undergraduate-timetable.html')
|
final content = File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import 'package:hepan_everyday/data/schedule_file_import.dart';
|
|||||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test('missing course fields identify the failing field without disclosing values', () {
|
||||||
|
const base =
|
||||||
|
'new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
||||||
|
'index=0*unitCount+0;table0.activities[index][table0.activities[index].length]=activity;';
|
||||||
|
for (final entry in {
|
||||||
|
'teacher_missing': base.replaceFirst('"教师"', '""'),
|
||||||
|
'room_missing': base.replaceFirst('"教室"', '""'),
|
||||||
|
'course_sequence_invalid': base.replaceFirst('课程(C.01)', '课程'),
|
||||||
|
}.entries) {
|
||||||
|
expect(
|
||||||
|
() => parseActivityData(entry.value, unitCount: 12),
|
||||||
|
throwsA(
|
||||||
|
isA<UndergraduateActivityException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'reason',
|
||||||
|
entry.key,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
test('unrecognized activity writes and incomplete declarations reject the entire timetable', () {
|
test('unrecognized activity writes and incomplete declarations reject the entire timetable', () {
|
||||||
const valid =
|
const valid =
|
||||||
'activity = new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
'activity = new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'leading comments do not remove course declarations from the evidence',
|
||||||
|
() {
|
||||||
|
final store = SchoolPageDiagnostic();
|
||||||
|
addTearDown(store.clear);
|
||||||
|
final source = File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync()
|
||||||
|
.replaceFirst(
|
||||||
|
'var unitCount = 12;',
|
||||||
|
'// units; keep this declaration\nvar unitCount = 12;',
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
'activity = new TaskActivity',
|
||||||
|
'/* course; declaration */activity = new TaskActivity',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
UndergraduateTimetableAdapter.extract(source, uri).courses,
|
||||||
|
hasLength(18),
|
||||||
|
);
|
||||||
|
store.remember(source, uri);
|
||||||
|
expect(
|
||||||
|
UndergraduateTimetableAdapter.extract(
|
||||||
|
utf8.decode(store.export()!),
|
||||||
|
uri,
|
||||||
|
).courses,
|
||||||
|
hasLength(18),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('malformed activity and index statements remain malformed in the exported evidence', () {
|
||||||
|
final store = SchoolPageDiagnostic();
|
||||||
|
addTearDown(store.clear);
|
||||||
|
final fixture = File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync();
|
||||||
|
for (final bad in [
|
||||||
|
fixture.replaceFirst('"教师1"', 'null'),
|
||||||
|
fixture.replaceFirst('2*unitCount+0', 'unknown()'),
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
() => UndergraduateTimetableAdapter.extract(bad, uri),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
store.remember(bad, uri);
|
||||||
|
final output = utf8.decode(store.export()!);
|
||||||
|
expect(
|
||||||
|
() => UndergraduateTimetableAdapter.extract(output, uri),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(output, contains(bad.contains('null') ? 'null' : 'unknown()'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('diagnostic snapshot preserves parser evidence but removes login material and active HTML', () {
|
||||||
|
var now = DateTime.utc(2026, 10, 16, 5);
|
||||||
|
final store = SchoolPageDiagnostic(clock: () => now);
|
||||||
|
final fixture = File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync();
|
||||||
|
store.remember(
|
||||||
|
'''$fixture<input name="password" value="SECRET"><input name="params" value="ticket=SECRET"><a href="https://evil.test/?ticket=SECRET" onclick="steal()">链接</a><script>var token="SECRET";</script>''',
|
||||||
|
uri,
|
||||||
|
);
|
||||||
|
final output = utf8.decode(store.export()!);
|
||||||
|
expect(output, isNot(contains('SECRET')));
|
||||||
|
expect(output, isNot(contains('onclick')));
|
||||||
|
expect(output, isNot(contains('evil.test')));
|
||||||
|
expect(output, contains('Content-Security-Policy'));
|
||||||
|
expect(
|
||||||
|
UndergraduateTimetableAdapter.extract(output, uri).courses,
|
||||||
|
hasLength(18),
|
||||||
|
);
|
||||||
|
now = now.add(const Duration(minutes: 11));
|
||||||
|
expect(store.export(), isNull);
|
||||||
|
});
|
||||||
|
test('only trusted school evidence can be kept and logout clears it', () {
|
||||||
|
final store = SchoolPageDiagnostic();
|
||||||
|
store.remember('<table id="manualArrangeCourseTable"></table>', uri);
|
||||||
|
expect(store.export(), isNotNull);
|
||||||
|
store.clear();
|
||||||
|
expect(store.export(), isNull);
|
||||||
|
store.remember(
|
||||||
|
'<table>secret</table>',
|
||||||
|
Uri.parse('https://idas.uestc.edu.cn/authserver/login'),
|
||||||
|
);
|
||||||
|
expect(store.export(), isNull);
|
||||||
|
store.remember(
|
||||||
|
'<input type="password"><table id="manualArrangeCourseTable"></table>',
|
||||||
|
uri,
|
||||||
|
);
|
||||||
|
expect(store.export(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
|||||||
import 'package:hepan_everyday/app/app_controller.dart';
|
import 'package:hepan_everyday/app/app_controller.dart';
|
||||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||||
import 'package:hepan_everyday/domain/school_term.dart';
|
import 'package:hepan_everyday/domain/school_term.dart';
|
||||||
|
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||||
|
|
||||||
import '../support/fake_school_web.dart';
|
import '../support/fake_school_web.dart';
|
||||||
import '../support/fakes.dart';
|
import '../support/fakes.dart';
|
||||||
@@ -82,6 +83,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(controller.snapshot.courses, hasLength(18));
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
expect(find.text('打开'), findsOneWidget);
|
expect(find.text('打开'), findsOneWidget);
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
testWidgets('cross school replacement can be cancelled without saving', (
|
testWidgets('cross school replacement can be cancelled without saving', (
|
||||||
tester,
|
tester,
|
||||||
@@ -100,6 +102,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(controller.snapshot.toJson(), previous);
|
expect(controller.snapshot.toJson(), previous);
|
||||||
expect(find.text('导入当前课表'), findsOneWidget);
|
expect(find.text('导入当前课表'), findsOneWidget);
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
testWidgets('failed saving stays on the browser and can be retried', (
|
testWidgets('failed saving stays on the browser and can be retried', (
|
||||||
tester,
|
tester,
|
||||||
@@ -114,6 +117,7 @@ void main() {
|
|||||||
await tester.tap(find.text('重试保存'));
|
await tester.tap(find.text('重试保存'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(controller.snapshot.courses, hasLength(18));
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
testWidgets('an obsolete deferred save does not strand a newer import', (
|
testWidgets('an obsolete deferred save does not strand a newer import', (
|
||||||
tester,
|
tester,
|
||||||
@@ -125,5 +129,6 @@ void main() {
|
|||||||
platform.delegate.finished(platform.web.url!);
|
platform.delegate.finished(platform.web.url!);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(controller.snapshot.courses, hasLength(18));
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.g
|
|||||||
| `school` | Must be omitted | Required, nonblank, max 200 characters |
|
| `school` | Must be omitted | Required, nonblank, max 200 characters |
|
||||||
| `attachments` | Optional, max 3 files, 8 MiB each, 20 MiB aggregate | Must be omitted |
|
| `attachments` | Optional, max 3 files, 8 MiB each, 20 MiB aggregate | Must be omitted |
|
||||||
| `diagnostics` | Optional single UTF-8 text file, max 256 KiB, independent of media count; starts with `TPSchedule diagnostic log` followed by a newline | Must be omitted |
|
| `diagnostics` | Optional single UTF-8 text file, max 256 KiB, independent of media count; starts with `TPSchedule diagnostic log` followed by a newline | Must be omitted |
|
||||||
|
| `diagnostic_html` | Optional single UTF-8 HTML file, max 2 MiB, independent of media count; starts with `<!doctype html><!-- TPSchedule school diagnostic -->` | Must be omitted |
|
||||||
|
|
||||||
The app defaults diagnostics to off. When selected it snapshots the preceding ten minutes of structured app/login/import/reminder events, at most 500 entries. It excludes credentials, cookies, raw URLs, HTML, timetable contents and feedback text. The server attaches this file as `TPSchedule-diagnostics.txt`; retries must use the identical snapshot and submission ID. The server hashes it with the other attachments for idempotency, and does not log its contents.
|
The app defaults diagnostics to off. When selected it snapshots the preceding ten minutes of structured app/login/import/reminder events, at most 500 entries. It excludes credentials, cookies, raw URLs, HTML, timetable contents and feedback text. The server attaches this file as `TPSchedule-diagnostics.txt`; retries must use the identical snapshot and submission ID. The server hashes it with the other attachments for idempotency, and does not log its contents.
|
||||||
|
|
||||||
@@ -75,7 +76,9 @@ To resolve an uncertain record, first check the recipient mailbox and provider d
|
|||||||
|
|
||||||
## Limits and operations
|
## Limits and operations
|
||||||
|
|
||||||
The service counts raw HTTP bytes, including chunked input, with a 20 MiB + 64 KiB request ceiling; the proxy allows 21 MiB to leave the final precise check to the service. Text parts, field count and file count are bounded. Four active feedback requests are allowed by default, with a 60-second upload deadline. Default limits are ten requests/IP/hour and 100 requests globally/hour. These include invalid requests and retries. The in-memory rate counters reset after restart; SQLite deduplication survives. Proxy IP headers are overwritten; the private upstream must stay private.
|
The service counts raw HTTP bytes, including chunked input, with a 20 MiB + 256 KiB + 2 MiB + 64 KiB request ceiling; the proxy allows 24 MiB to leave the final precise check to the service. Text parts, field count and file count are bounded. Four active feedback requests are allowed by default, with a 60-second upload deadline. Default limits are ten requests/IP/hour and 100 requests globally/hour. These include invalid requests and retries. The in-memory rate counters reset after restart; SQLite deduplication survives. Proxy IP headers are overwritten; the private upstream must stay private.
|
||||||
|
|
||||||
|
The school HTML attachment is a separate explicit opt-in with a privacy dialog, not part of structured logs. Only the latest undergraduate import evidence is retained in memory for ten minutes and cleared on logout. Its known course statements are inert, attributes and unrelated scripts/forms are removed, and it carries a restrictive CSP. Failed delivery retries freeze the same bytes and submission ID. See [0.7.5 HTML附件更新](html-diagnostic-update.md) for existing 1Panel/SafeLine deployments.
|
||||||
|
|
||||||
Run exactly **one worker and one service replica** with this configuration: rate/concurrency counters are per-process. SQLite claims serialize concurrent IDs, but scaling needs a shared rate limiter and an appropriate storage design. The default 512 MiB memory and 256 MiB tmpfs budget account for multipart and MIME encoding of several uploads; if reducing these, also reduce concurrency. Large attachments can grow by roughly a third in email MIME encoding and may be rejected by provider message-size limits; a definite rejection returns a failure.
|
Run exactly **one worker and one service replica** with this configuration: rate/concurrency counters are per-process. SQLite claims serialize concurrent IDs, but scaling needs a shared rate limiter and an appropriate storage design. The default 512 MiB memory and 256 MiB tmpfs budget account for multipart and MIME encoding of several uploads; if reducing these, also reduce concurrency. Large attachments can grow by roughly a third in email MIME encoding and may be rejected by provider message-size limits; a definite rejection returns a failure.
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ curl --fail --max-time 10 -H 'Host: app.glowtps.cn' http://127.0.0.1:10080/healt
|
|||||||
|
|
||||||
该接口来自原生APP HTTP客户端,无法执行网页JavaScript或拖动验证码。对这个API站点或精确路径 `/v1/feedback`、`/healthz` 避免开启浏览器人机验证、网页登录跳转、动态JS注入和等待室。根据版本提供的站点/路径配置范围操作,保留适用于API的攻击检测及频率限制;不要通过全站攻击白名单解决误拦。
|
该接口来自原生APP HTTP客户端,无法执行网页JavaScript或拖动验证码。对这个API站点或精确路径 `/v1/feedback`、`/healthz` 避免开启浏览器人机验证、网页登录跳转、动态JS注入和等待室。根据版本提供的站点/路径配置范围操作,保留适用于API的攻击检测及频率限制;不要通过全站攻击白名单解决误拦。
|
||||||
|
|
||||||
图片、视频请求使用multipart/form-data。雷池此站点应允许约21MiB的HTTP请求体,回源等待时间设为至少60秒(如支持,可与OpenResty设置一致为180秒)。服务自己仍限制最多3个附件、单个8MiB、合计20MiB。雷池HTTP请求体限额不足时会在服务之前返回413;浏览器挑战可能返回HTML甚至200,APP会拒绝把它当作提交成功。
|
图片、视频请求使用multipart/form-data。雷池此站点应允许至少24MiB的HTTP请求体,回源等待时间设为至少60秒(如支持,可与OpenResty设置一致为180秒)。服务自己仍限制最多3个附件、单个8MiB、合计20MiB。雷池HTTP请求体限额不足时会在服务之前返回413;浏览器挑战可能返回HTML甚至200,APP会拒绝把它当作提交成功。
|
||||||
|
|
||||||
## 6. 验证整条链路及邮件
|
## 6. 验证整条链路及邮件
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ APP 的「反馈问题和建议」新增默认关闭的「附带近10分钟诊
|
|||||||
1. 在1Panel找到原反馈服务的目录,例如 `/opt/app-feedback`。保留原 `.env`、Compose项目名和数据库数据卷,不重建SMTP密码及 `IDEMPOTENCY_SECRET`。
|
1. 在1Panel找到原反馈服务的目录,例如 `/opt/app-feedback`。保留原 `.env`、Compose项目名和数据库数据卷,不重建SMTP密码及 `IDEMPOTENCY_SECRET`。
|
||||||
2. 用本版本的 `feedback_service.py` 覆盖同名文件;同时保留本工程的 `main.py`、依赖及Docker构建文件。若统一上传整个部署目录,排除 `.deps`、测试缓存、本机文件及 `.env`,不要覆盖服务器原配置。
|
2. 用本版本的 `feedback_service.py` 覆盖同名文件;同时保留本工程的 `main.py`、依赖及Docker构建文件。若统一上传整个部署目录,排除 `.deps`、测试缓存、本机文件及 `.env`,不要覆盖服务器原配置。
|
||||||
3. 在该目录运行 `docker compose up -d --build feedback`,或在1Panel中重建同一Compose服务。等待健康检查通过。不要执行删除数据卷的操作。
|
3. 在该目录运行 `docker compose up -d --build feedback`,或在1Panel中重建同一Compose服务。等待健康检查通过。不要执行删除数据卷的操作。
|
||||||
4. 雷池80/443 → OpenResty10080 → `127.0.0.1:18080` 的原转发关系保持;原 `client_max_body_size 21m` 上限足以容纳20 MiB媒体和256 KiB日志,不需更换域名或端口。
|
4. 雷池80/443 → OpenResty10080 → `127.0.0.1:18080` 的原转发关系保持;0.7.5增加HTML附件后,需将 `client_max_body_size` 调为 `24m`,雷池请求体限制至少24MiB;不需更换域名或端口,详见 [HTML附件更新](html-diagnostic-update.md)。
|
||||||
5. 安装新APP,尝试登录并复现问题,再进入反馈页写问题、勾选日志、提交。到 `tps-david@glowtps.cn` 核对文本附件及时间;该步骤才会真实发送邮件。
|
5. 安装新APP,尝试登录并复现问题,再进入反馈页写问题、勾选日志、提交。到 `tps-david@glowtps.cn` 核对文本附件及时间;该步骤才会真实发送邮件。
|
||||||
|
|
||||||
若服务器仍为旧版,勾选日志可能被拒绝,APP不会显示发送成功,填写内容保留。不勾选日志的旧协议继续可用。本次仅准备与测试部署代码,未连接服务器或实发SMTP邮件。
|
若服务器仍为旧版,勾选日志可能被拒绝,APP不会显示发送成功,填写内容保留。不勾选日志的旧协议继续可用。本次仅准备与测试部署代码,未连接服务器或实发SMTP邮件。
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from dataclasses import dataclass, field
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.policy import SMTP
|
from email.policy import SMTP
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -30,7 +31,8 @@ MIB = 1024 * 1024
|
|||||||
FILE_LIMIT = 8 * MIB
|
FILE_LIMIT = 8 * MIB
|
||||||
TOTAL_FILE_LIMIT = 20 * MIB
|
TOTAL_FILE_LIMIT = 20 * MIB
|
||||||
LOG_LIMIT = 256 * 1024
|
LOG_LIMIT = 256 * 1024
|
||||||
BODY_LIMIT = TOTAL_FILE_LIMIT + LOG_LIMIT + 64 * 1024
|
HTML_LIMIT = 2 * MIB
|
||||||
|
BODY_LIMIT = TOTAL_FILE_LIMIT + LOG_LIMIT + HTML_LIMIT + 64 * 1024
|
||||||
ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{16,128}\Z")
|
ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{16,128}\Z")
|
||||||
ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}\Z")
|
ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}\Z")
|
||||||
|
|
||||||
@@ -105,6 +107,95 @@ class ApiError(Exception):
|
|||||||
self.submission_id, self.status = submission_id, status
|
self.submission_id, self.status = submission_id, status
|
||||||
|
|
||||||
|
|
||||||
|
class SchoolHtmlValidator(HTMLParser):
|
||||||
|
"""Accept only the inert diagnostic schema emitted by the app, not web pages."""
|
||||||
|
tags = set(('html head meta title body input table thead tbody tfoot tr td th caption colgroup col '
|
||||||
|
'span div p b i strong em u small br hr a font center nobr label s sub sup pre code').split())
|
||||||
|
void = {'meta', 'input', 'br', 'hr', 'col'}
|
||||||
|
csp = "default-src 'none'; base-uri 'none'; form-action 'none'"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.stack, self.policy, self.charset, self.root_count = [], False, False, 0
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
values = dict(attrs)
|
||||||
|
if len(values) != len(attrs) or tag not in self.tags | {'script'}:
|
||||||
|
raise ValueError('tag')
|
||||||
|
if tag == 'html':
|
||||||
|
self.root_count += 1
|
||||||
|
if self.stack or self.root_count != 1:
|
||||||
|
raise ValueError('root')
|
||||||
|
elif not self.stack:
|
||||||
|
raise ValueError('outside root')
|
||||||
|
if tag == 'meta':
|
||||||
|
if self.stack[-1:] != ['head']:
|
||||||
|
raise ValueError('metadata location')
|
||||||
|
if values == {'charset': 'utf-8'}:
|
||||||
|
self.charset = True
|
||||||
|
elif values == {'http-equiv': 'Content-Security-Policy', 'content': self.csp}:
|
||||||
|
self.policy = True
|
||||||
|
else:
|
||||||
|
raise ValueError('metadata')
|
||||||
|
elif tag == 'input':
|
||||||
|
if (set(values) != {'type', 'name', 'value'} or values['type'] != 'hidden'
|
||||||
|
or values['name'] not in {'semester.id', 'examType.id'}
|
||||||
|
or not re.fullmatch(r'\d{1,6}', values['value'] or '')):
|
||||||
|
raise ValueError('input')
|
||||||
|
elif tag == 'script':
|
||||||
|
if values != {'type': 'application/x-tpschedule-course-data'}:
|
||||||
|
raise ValueError('script')
|
||||||
|
else:
|
||||||
|
for key, value in attrs:
|
||||||
|
if key == 'id' and tag == 'table' and value == 'manualArrangeCourseTable':
|
||||||
|
continue
|
||||||
|
if key in {'rowspan', 'colspan'} and re.fullmatch(r'\d{1,2}', value or ''):
|
||||||
|
continue
|
||||||
|
raise ValueError('attribute')
|
||||||
|
if tag not in self.void:
|
||||||
|
self.stack.append(tag)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if not self.stack or self.stack.pop() != tag:
|
||||||
|
raise ValueError('unbalanced')
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag, attrs):
|
||||||
|
if tag not in self.void:
|
||||||
|
raise ValueError('self closing')
|
||||||
|
self.handle_starttag(tag, attrs)
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if not self.stack and data.strip():
|
||||||
|
raise ValueError('outside root')
|
||||||
|
if self.stack[-1:] == ['script'] and '<' in data:
|
||||||
|
raise ValueError('script delimiter')
|
||||||
|
|
||||||
|
def handle_comment(self, data):
|
||||||
|
if '[if' in data.lower() or '--' in data:
|
||||||
|
raise ValueError('conditional comment')
|
||||||
|
|
||||||
|
def handle_decl(self, decl):
|
||||||
|
if decl.lower() != 'doctype html':
|
||||||
|
raise ValueError('doctype')
|
||||||
|
|
||||||
|
def unknown_decl(self, data):
|
||||||
|
raise ValueError('declaration')
|
||||||
|
|
||||||
|
def handle_pi(self, data):
|
||||||
|
raise ValueError('processing instruction')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, text):
|
||||||
|
try:
|
||||||
|
parser = cls()
|
||||||
|
parser.feed(text)
|
||||||
|
parser.close()
|
||||||
|
if parser.stack or not parser.policy or not parser.charset or parser.root_count != 1:
|
||||||
|
raise ValueError('incomplete')
|
||||||
|
except (ValueError, AssertionError):
|
||||||
|
raise ApiError(415, 'unsupported_media', 'School HTML must use the inert diagnostic schema.') from None
|
||||||
|
|
||||||
|
|
||||||
class SendFailure(Exception):
|
class SendFailure(Exception):
|
||||||
"""SMTP definitely did not accept this message. Retrying is safe."""
|
"""SMTP definitely did not accept this message. Retrying is safe."""
|
||||||
|
|
||||||
@@ -363,10 +454,15 @@ def safe_filename(filename, extension):
|
|||||||
|
|
||||||
async def parse_submission(request):
|
async def parse_submission(request):
|
||||||
try:
|
try:
|
||||||
async with request.form(max_files=4, max_fields=5, max_part_size=20000) as form:
|
async with request.form(max_files=5, max_fields=5, max_part_size=20000) as form:
|
||||||
fields, uploads, diagnostic_upload = {}, [], None
|
fields, uploads, diagnostic_upload, html_upload = {}, [], None, None
|
||||||
for name, value in form.multi_items():
|
for name, value in form.multi_items():
|
||||||
if isinstance(value, UploadFile):
|
if isinstance(value, UploadFile):
|
||||||
|
if name == "diagnostic_html":
|
||||||
|
if html_upload is not None:
|
||||||
|
raise ApiError(422, "validation_error", "Only one school HTML is allowed.")
|
||||||
|
html_upload = value
|
||||||
|
continue
|
||||||
if name == "diagnostics":
|
if name == "diagnostics":
|
||||||
if diagnostic_upload is not None:
|
if diagnostic_upload is not None:
|
||||||
raise ApiError(422, "validation_error", "Only one diagnostic log is allowed.")
|
raise ApiError(422, "validation_error", "Only one diagnostic log is allowed.")
|
||||||
@@ -393,7 +489,7 @@ async def parse_submission(request):
|
|||||||
if kind == "problem":
|
if kind == "problem":
|
||||||
if not fields.get("message", "").strip() or "school" in fields:
|
if not fields.get("message", "").strip() or "school" in fields:
|
||||||
raise ApiError(422, "validation_error", "Problem feedback requires message and must omit school.")
|
raise ApiError(422, "validation_error", "Problem feedback requires message and must omit school.")
|
||||||
elif ("message" in fields or uploads or diagnostic_upload or not fields.get("school", "").strip()
|
elif ("message" in fields or uploads or diagnostic_upload or html_upload or not fields.get("school", "").strip()
|
||||||
or not fields.get("contact", "").strip()):
|
or not fields.get("contact", "").strip()):
|
||||||
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
|
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
|
||||||
attachments, total = [], 0
|
attachments, total = [], 0
|
||||||
@@ -420,6 +516,19 @@ async def parse_submission(request):
|
|||||||
if (diagnostic_upload.content_type or '').split(';')[0].strip().lower() != 'text/plain' or '\x00' in text or not text.startswith('TPSchedule diagnostic log\n'):
|
if (diagnostic_upload.content_type or '').split(';')[0].strip().lower() != 'text/plain' or '\x00' in text or not text.startswith('TPSchedule diagnostic log\n'):
|
||||||
raise ApiError(415, "unsupported_media", "Invalid diagnostic log.")
|
raise ApiError(415, "unsupported_media", "Invalid diagnostic log.")
|
||||||
attachments.append(('TPSchedule-diagnostics.txt', 'text/plain', data))
|
attachments.append(('TPSchedule-diagnostics.txt', 'text/plain', data))
|
||||||
|
if html_upload is not None:
|
||||||
|
data = await html_upload.read(HTML_LIMIT + 1)
|
||||||
|
if len(data) > HTML_LIMIT:
|
||||||
|
raise ApiError(413, "upload_too_large", "School HTML exceeds 2 MiB.")
|
||||||
|
try:
|
||||||
|
text = data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise ApiError(415, "unsupported_media", "School HTML must be UTF-8.") from None
|
||||||
|
if ((html_upload.content_type or '').split(';')[0].strip().lower() != 'text/html'
|
||||||
|
or '\x00' in text or not text.startswith('<!doctype html><!-- TPSchedule school diagnostic -->')):
|
||||||
|
raise ApiError(415, "unsupported_media", "Invalid school HTML diagnostic.")
|
||||||
|
SchoolHtmlValidator.validate(text)
|
||||||
|
attachments.append(('TPSchedule-school-page.html', 'text/html', data))
|
||||||
return fields, attachments
|
return fields, attachments
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
detail = str(exc.detail).lower()
|
detail = str(exc.detail).lower()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 0.7.5:可选本科导入HTML邮件附件更新
|
||||||
|
|
||||||
|
本次更新为你已部署的同一个反馈服务增加 `diagnostic_html` 文件字段。客户端默认关闭,需要学生在“反馈与共建→反馈问题和建议”勾选日志、勾选HTML并同意提示;邮件除日志外另附 `TPSchedule-school-page.html`,不占3个图片/视频名额。
|
||||||
|
|
||||||
|
## 你需要在1Panel执行的更新
|
||||||
|
|
||||||
|
1. 下载并解压 `TPSchedule-feedback-server.zip`,找到其中 `feedback/feedback_service.py`,覆盖服务器原部署目录中的同名文件,例如 `/opt/app-feedback/feedback_service.py`。不要覆盖服务器 `.env`,保留原 SMTP 密码、`IDEMPOTENCY_SECRET` 和数据库数据卷。
|
||||||
|
2. 在1Panel的 `app.glowtps.cn` 站点配置中,把原 `client_max_body_size 21m;` 改成 `client_max_body_size 24m;`,验证并重载OpenResty配置。雷池该站点如果有请求体大小限制,也提高到至少24MiB(若单位为字节则25165824)。原域名、证书、80/443→10080→18080转发不变。
|
||||||
|
3. 在原目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/app-feedback
|
||||||
|
docker compose up -d --build feedback
|
||||||
|
docker compose ps
|
||||||
|
curl --fail --max-time 10 http://127.0.0.1:18080/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查返回 `{"status":"ok"}` 后再测试APP。不要删除数据卷,不要另建新的Compose项目。
|
||||||
|
4. 同学安装0.7.5,在学校网页中打开课表并点击一次“导入当前课表”,失败后返回“我的→反馈与共建”。在10分钟内写明错误并勾选日志、HTML,确认后提交。考试读取失败同样保留最后一次捕获的考试表格。到 `tps-david@glowtps.cn` 核对两个附件;此步骤会真实发送邮件,由你操作。
|
||||||
|
|
||||||
|
## 数据与兼容性
|
||||||
|
|
||||||
|
- HTML只含本科课表/课程名单或考试表格、学期/考试类型数字,以及解析需要的课程字面量脚本;移除学校页面账户头部、表单、链接地址、事件处理和无关脚本,不采集Cookie。附件中的课程脚本不执行,页面附带禁止网络请求的CSP。
|
||||||
|
- 课程、教师、教室、考场及可能存在的个人信息用于排查,仍应按个人资料妥善处理。HTML不是原始整页的逐字备份。
|
||||||
|
- 未提交的采集缓存仅在APP内存保留最多10分钟;退出学校登录、重新认证或开始新的导入会清除。勾选后实际提交时冻结附件,失败重试沿用同一编号和字节;重试表单关闭或APP结束后该冻结副本释放。
|
||||||
|
- HTML最多2MiB,日志256KiB,媒体仍最多3个、每个8MiB、合计20MiB。总HTTP请求上限为20MiB+256KiB+2MiB+64KiB;代理放行24MiB。
|
||||||
|
- 旧客户端不带新字段仍可使用。未更新服务端时,新附件可能被拒绝;APP保留内容并提示更新,取消HTML后仍可只发日志。
|
||||||
|
- 本工程只准备并测试代码,没有登录你的服务器、改配置或发送真实邮件。
|
||||||
@@ -8,7 +8,7 @@ real_ip_header X-Forwarded-For;
|
|||||||
real_ip_recursive on;
|
real_ip_recursive on;
|
||||||
|
|
||||||
location = /v1/feedback {
|
location = /v1/feedback {
|
||||||
client_max_body_size 21m;
|
client_max_body_size 24m;
|
||||||
client_body_timeout 60s;
|
client_body_timeout 60s;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# Set the site to force HTTPS. Do not expose the upstream port publicly.
|
# Set the site to force HTTPS. Do not expose the upstream port publicly.
|
||||||
|
|
||||||
location = /v1/feedback {
|
location = /v1/feedback {
|
||||||
client_max_body_size 21m;
|
client_max_body_size 24m;
|
||||||
client_body_timeout 60s;
|
client_body_timeout 60s;
|
||||||
# Avoid persistent proxy copies of feedback media.
|
# Avoid persistent proxy copies of feedback media.
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!doctype html><!-- TPSchedule school diagnostic --><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><input type="hidden" name="semester.id" value="523"><table id="manualArrangeCourseTable"><tbody><tr><th>节次</th><th>星期一</th><th>星期二</th><th>星期三</th><th>星期四</th><th>星期五</th><th>星期六</th><th>星期日</th></tr></tbody></table><table><tbody><tr><th>序号</th><th>课程代码</th><th>课程名称</th><th>课程类别</th><th>学分</th><th>课程序号</th><th>教师</th><th>职称</th><th>教材信息</th><th>选课备注</th></tr><tr><td>1</td><td>C1</td><td>课程1</td><td>专业课</td><td>3</td><td>C1.01</td><td>教师1</td><td></td><td></td><td>上课时间:19:00-22:00</td></tr><tr><td>2</td><td>C2</td><td>课程2</td><td>专业课</td><td>3</td><td>C2.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>3</td><td>C3</td><td>课程3</td><td>专业课</td><td>3</td><td>C3.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>4</td><td>C4</td><td>课程4</td><td>专业课</td><td>3</td><td>C4.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>5</td><td>C5</td><td>课程5</td><td>专业课</td><td>3</td><td>C5.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>6</td><td>C6</td><td>课程6</td><td>专业课</td><td>3</td><td>C6.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>7</td><td>C7</td><td>课程7</td><td>专业课</td><td>3</td><td>C7.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>8</td><td>C8</td><td>课程8</td><td>专业课</td><td>3</td><td>C8.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>9</td><td>C9</td><td>课程9</td><td>专业课</td><td>3</td><td>C9.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>10</td><td>C10</td><td>课程10</td><td>专业课</td><td>3</td><td>C10.01</td><td>教师1</td><td></td><td></td><td></td></tr></tbody></table><script type="application/x-tpschedule-course-data">var unitCount = 12;
|
||||||
|
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室1","00000000000001000000000000000000000000000000000000000");
|
||||||
|
index = 2*unitCount+0;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 2*unitCount+1;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室1","00000000001101000000000000000000000000000000000000000");
|
||||||
|
index = 4*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 4*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室2","01110011110010000000000000000000000000000000000000000");
|
||||||
|
index = 4*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 4*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室2","01111111111110000000000000000000000000000000000000000");
|
||||||
|
index = 2*unitCount+0;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 2*unitCount+1;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T2","教师2","COURSE2","课程2(C2.01)","R2","教室3","01111000000000000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+8;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+9;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+10;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+11;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T2","教师2","COURSE2","课程2(C2.01)","R2","教室4","00000111100000000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+8;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+9;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+10;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+11;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T3","教师3","COURSE3","课程3(C3.01)","R3","教室5","01111011111110000000000000000000000000000000000000000");
|
||||||
|
index = 3*unitCount+4;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 3*unitCount+5;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T3","教师3","COURSE3","课程3(C3.01)","R3","教室5","01111111111111000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+0;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+1;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T4","教师4","COURSE4","课程4(C4.01)","R4","教室6","01111011111000000000000000000000000000000000000000000");
|
||||||
|
index = 3*unitCount+0;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 3*unitCount+1;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T4","教师4","COURSE4","课程4(C4.01)","R4","教室6","01111111111100000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T5","教师5","COURSE5","课程5(C5.01)","R5","教室7","01111111111111111110000000000000000000000000000000000");
|
||||||
|
index = 0*unitCount+8;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 0*unitCount+9;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 0*unitCount+10;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 0*unitCount+11;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T6","教师6、教师7","COURSE6","课程6(C6.01)","R6","教室8","01111111111110000000000000000000000000000000000000000");
|
||||||
|
index = 2*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 2*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T6","教师6、教师7","COURSE6","课程6(C6.01)","R6","教室8","01111111111110000000000000000000000000000000000000000");
|
||||||
|
index = 0*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 0*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T7","教师8、教师9、教师10、教师7、教师11、教师12、教师13、教师14、教师15","COURSE7","课程7(C7.01)","R7","教室9","00001011111111111000000000000000000000000000000000000");
|
||||||
|
index = 3*unitCount+6;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 3*unitCount+7;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T8","教师16","COURSE8","课程8(C8.01)","R8","教室10","01111101111110000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+4;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+5;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+6;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+7;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T8","教师16","COURSE8","课程8(C8.01)","R8","停课","00000010000000000000000000000000000000000000000000000");
|
||||||
|
index = 1*unitCount+4;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+5;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+6;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 1*unitCount+7;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T9","教师17","COURSE9","课程9(C9.01)","R9","教室12","00000000000000010000000000000000000000000000000000000");
|
||||||
|
index = 6*unitCount+2;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 6*unitCount+3;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T10","教师18","COURSE10","课程10(C10.01)","R10","教室13","01111111111110000000000000000000000000000000000000000");
|
||||||
|
index = 0*unitCount+4;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 0*unitCount+5;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
activity = new TaskActivity("T10","教师18","COURSE10","课程10(C10.01)","R10","教室13","01111111111110000000000000000000000000000000000000000");
|
||||||
|
index = 2*unitCount+4;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;
|
||||||
|
index = 2*unitCount+5;
|
||||||
|
table0.activities[index][table0.activities[index].length]=activity;</script></body></html>
|
||||||
@@ -4,6 +4,7 @@ import threading
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import smtplib
|
import smtplib
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import feedback_service
|
import feedback_service
|
||||||
|
|
||||||
@@ -15,6 +16,66 @@ from feedback_service import Settings, SendFailure, UncertainFailure, SmtpSender
|
|||||||
|
|
||||||
ID = "cbaaaace-60b2-4871-8a95-ae316444a2fb"
|
ID = "cbaaaace-60b2-4871-8a95-ae316444a2fb"
|
||||||
PNG = b"\x89PNG\r\n\x1a\n" + b"safe fixture"
|
PNG = b"\x89PNG\r\n\x1a\n" + b"safe fixture"
|
||||||
|
HTML = (b'<!doctype html><!-- TPSchedule school diagnostic --><html><head><meta charset="utf-8">'
|
||||||
|
b'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
|
||||||
|
b'</head><body><table></table></body></html>')
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_client_generated_synthetic_html_is_accepted_as_attachment(service):
|
||||||
|
client, sender, _ = service
|
||||||
|
page = (Path(__file__).parent / 'fixtures/client-school-diagnostic.html').read_bytes()
|
||||||
|
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
|
||||||
|
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', page, 'text/html'))])
|
||||||
|
assert response.status_code == 200
|
||||||
|
attachment = list(sender.messages[0].iter_attachments())[0]
|
||||||
|
assert attachment.get_payload(decode=True) == page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('extra', [
|
||||||
|
'<script>alert(1)</script>', '<img src="https://evil.test/">',
|
||||||
|
'<table onclick="alert(1)"></table>', '<meta http-equiv="refresh" content="0;url=https://evil.test/">',
|
||||||
|
'<iframe srcdoc="x"></iframe>',
|
||||||
|
])
|
||||||
|
def test_html_diagnostic_rejects_active_or_network_content_even_with_marker(service, extra):
|
||||||
|
client, sender, _ = service
|
||||||
|
data = HTML.replace(b'</body>', extra.encode() + b'</body>')
|
||||||
|
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
|
||||||
|
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', data, 'text/html'))])
|
||||||
|
assert response.status_code == 415
|
||||||
|
assert not sender.messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_diagnostic_is_fifth_attachment_and_retry_uses_identical_snapshot(service):
|
||||||
|
client, sender, _ = service
|
||||||
|
files = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
|
||||||
|
files += [('attachments', (f'{i}.png', PNG, 'image/png')) for i in range(3)]
|
||||||
|
files += [('diagnostics', ('log.txt', b'TPSchedule diagnostic log\n', 'text/plain')),
|
||||||
|
('diagnostic_html', ('page.html', HTML, 'text/html; charset=utf-8'))]
|
||||||
|
assert client.post('/v1/feedback', files=files).status_code == 200
|
||||||
|
attachments = list(sender.messages[0].iter_attachments())
|
||||||
|
assert len(attachments) == 5
|
||||||
|
assert attachments[-1].get_filename() == 'TPSchedule-school-page.html'
|
||||||
|
assert attachments[-1].get_payload(decode=True) == HTML
|
||||||
|
assert client.post('/v1/feedback', files=files).status_code == 200
|
||||||
|
assert len(sender.messages) == 1
|
||||||
|
files[-1] = ('diagnostic_html', ('page.html', HTML.replace(b'<table>', b'<table><tr><td>changed</td></tr>'), 'text/html'))
|
||||||
|
assert client.post('/v1/feedback', files=files).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('data,mime,kind,status', [
|
||||||
|
(b'not HTML', 'text/html', 'problem', 415),
|
||||||
|
(HTML, 'image/png', 'problem', 415),
|
||||||
|
(HTML + b'\xff', 'text/html', 'problem', 415),
|
||||||
|
(HTML + b'x' * (2 * 1024 * 1024), 'text/html', 'problem', 413),
|
||||||
|
(HTML, 'text/html', 'development', 422),
|
||||||
|
], ids=['header', 'mime', 'utf8', 'oversize', 'development'])
|
||||||
|
def test_invalid_html_diagnostics_never_send(service, data, mime, kind, status):
|
||||||
|
client, sender, _ = service
|
||||||
|
fields = [('submission_id', (None, ID)), ('type', (None, kind))]
|
||||||
|
fields += [('message', (None, 'Cannot import'))] if kind == 'problem' else [('school', (None, 'School')), ('contact', (None, 'Contact'))]
|
||||||
|
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', data, mime))])
|
||||||
|
assert response.status_code == status
|
||||||
|
assert not sender.messages
|
||||||
|
|
||||||
|
|
||||||
class RecordingSender:
|
class RecordingSender:
|
||||||
@@ -251,8 +312,8 @@ def test_aggregate_attachments_are_limited(service):
|
|||||||
def test_chunked_body_without_content_length_is_limited(service):
|
def test_chunked_body_without_content_length_is_limited(service):
|
||||||
client, sender, _ = service
|
client, sender, _ = service
|
||||||
events, output = iter([
|
events, output = iter([
|
||||||
{"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": True},
|
{"type": "http.request", "body": b"x" * (12 * 1024 * 1024), "more_body": True},
|
||||||
{"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": False},
|
{"type": "http.request", "body": b"x" * (12 * 1024 * 1024), "more_body": False},
|
||||||
]), []
|
]), []
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
|
|||||||
Reference in New Issue
Block a user