fix: import unpublished exam rooms and add opt-in school HTML feedback

This commit is contained in:
Codex
2026-10-16 16:38:00 +08:00
parent defe7fd50e
commit 437ed14dbe
31 changed files with 969 additions and 39 deletions
+4 -1
View File
@@ -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 |
| `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 |
| `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.
@@ -75,7 +76,9 @@ To resolve an uncertain record, first check the recipient mailbox and provider d
## 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.
@@ -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的攻击检测及频率限制;不要通过全站攻击白名单解决误拦。
图片、视频请求使用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. 验证整条链路及邮件
+1 -1
View File
@@ -9,7 +9,7 @@ APP 的「反馈问题和建议」新增默认关闭的「附带近10分钟诊
1. 在1Panel找到原反馈服务的目录,例如 `/opt/app-feedback`。保留原 `.env`、Compose项目名和数据库数据卷,不重建SMTP密码及 `IDEMPOTENCY_SECRET`。
2. 用本版本的 `feedback_service.py` 覆盖同名文件;同时保留本工程的 `main.py`、依赖及Docker构建文件。若统一上传整个部署目录,排除 `.deps`、测试缓存、本机文件及 `.env`,不要覆盖服务器原配置。
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` 核对文本附件及时间;该步骤才会真实发送邮件。
若服务器仍为旧版,勾选日志可能被拒绝,APP不会显示发送成功,填写内容保留。不勾选日志的旧协议继续可用。本次仅准备与测试部署代码,未连接服务器或实发SMTP邮件。
+113 -4
View File
@@ -18,6 +18,7 @@ from dataclasses import dataclass, field
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import formatdate
from html.parser import HTMLParser
from pathlib import Path
from fastapi import FastAPI, Request
@@ -30,7 +31,8 @@ MIB = 1024 * 1024
FILE_LIMIT = 8 * MIB
TOTAL_FILE_LIMIT = 20 * MIB
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")
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
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):
"""SMTP definitely did not accept this message. Retrying is safe."""
@@ -363,10 +454,15 @@ def safe_filename(filename, extension):
async def parse_submission(request):
try:
async with request.form(max_files=4, max_fields=5, max_part_size=20000) as form:
fields, uploads, diagnostic_upload = {}, [], None
async with request.form(max_files=5, max_fields=5, max_part_size=20000) as form:
fields, uploads, diagnostic_upload, html_upload = {}, [], None, None
for name, value in form.multi_items():
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 diagnostic_upload is not None:
raise ApiError(422, "validation_error", "Only one diagnostic log is allowed.")
@@ -393,7 +489,7 @@ async def parse_submission(request):
if kind == "problem":
if not fields.get("message", "").strip() or "school" in fields:
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()):
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
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'):
raise ApiError(415, "unsupported_media", "Invalid diagnostic log.")
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
except HTTPException as exc:
detail = str(exc.detail).lower()
+28
View File
@@ -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;
location = /v1/feedback {
client_max_body_size 21m;
client_max_body_size 24m;
client_body_timeout 60s;
proxy_request_buffering off;
proxy_buffering off;
+1 -1
View File
@@ -4,7 +4,7 @@
# Set the site to force HTTPS. Do not expose the upstream port publicly.
location = /v1/feedback {
client_max_body_size 21m;
client_max_body_size 24m;
client_body_timeout 60s;
# Avoid persistent proxy copies of feedback media.
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>
+63 -2
View File
@@ -4,6 +4,7 @@ import threading
import tempfile
import time
import smtplib
from pathlib import Path
from types import SimpleNamespace
import feedback_service
@@ -15,6 +16,66 @@ from feedback_service import Settings, SendFailure, UncertainFailure, SmtpSender
ID = "cbaaaace-60b2-4871-8a95-ae316444a2fb"
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:
@@ -251,8 +312,8 @@ def test_aggregate_attachments_are_limited(service):
def test_chunked_body_without_content_length_is_limited(service):
client, sender, _ = service
events, output = iter([
{"type": "http.request", "body": b"x" * (11 * 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": True},
{"type": "http.request", "body": b"x" * (12 * 1024 * 1024), "more_body": False},
]), []
async def receive():