fix: harden school imports and add optional feedback diagnostics

This commit is contained in:
Codex
2026-10-15 10:37:32 +08:00
parent 674df1ceb3
commit 7299f4d8b6
25 changed files with 722 additions and 42 deletions
+5
View File
@@ -16,6 +16,11 @@ Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.g
| `contact` | Optional, max 200 characters | 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 |
| `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 |
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.
To enable this field on an existing deployment, replace the service source and rebuild the same Compose service, keeping `.env`, the idempotency secret and database volume. Existing clients without diagnostics remain compatible. Do not alter the SafeLine/OpenResty routing for this update. See [日志附件更新说明](diagnostic-log-update.md).
Accepted MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`, `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/webm`. Send an explicit matching MIME type; `application/octet-stream` is rejected. Container signatures must match. MOV/MP4/M4V/HEIC require an initial valid `ftyp` box with a recognized compatible brand; legacy MOV without `ftyp` is rejected. Checks identify file formats; they do not certify an attachment is harmless or a complete playable file.
+15
View File
@@ -0,0 +1,15 @@
# 可选诊断日志附件更新
APP 的「反馈问题和建议」新增默认关闭的「附带近10分钟诊断日志」。勾选后,反馈邮件多一个 `TPSchedule-diagnostics.txt`,不占用3个图片/视频名额。加入开发的申请不发送日志。
日志包含认证入口打开、页面开始/结束跳转、识别登录状态、请求读取课表/考试、解析成功/失败类别、保存及提醒调度状态。页面地址只保留「身份认证、研究生首页、课表、考试」等类别,不保存完整URL及查询参数。不记录密码、验证码、Cookie、HTML、课程/师生姓名、反馈正文。日志有UTC时间,仅保留运行期间最近10分钟、最多500条;运行时每分钟裁剪,重启时丢弃过期记录。重试使用首次提交时冻结的附件。
## 现有 Ubuntu + 1Panel + 雷池部署
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日志,不需更换域名或端口。
5. 安装新APP,尝试登录并复现问题,再进入反馈页写问题、勾选日志、提交。到 `tps-david@glowtps.cn` 核对文本附件及时间;该步骤才会真实发送邮件。
若服务器仍为旧版,勾选日志可能被拒绝,APP不会显示发送成功,填写内容保留。不勾选日志的旧协议继续可用。本次仅准备与测试部署代码,未连接服务器或实发SMTP邮件。
+23 -4
View File
@@ -29,7 +29,8 @@ from starlette.concurrency import run_in_threadpool
MIB = 1024 * 1024
FILE_LIMIT = 8 * MIB
TOTAL_FILE_LIMIT = 20 * MIB
BODY_LIMIT = TOTAL_FILE_LIMIT + 64 * 1024
LOG_LIMIT = 256 * 1024
BODY_LIMIT = TOTAL_FILE_LIMIT + LOG_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")
@@ -362,10 +363,15 @@ def safe_filename(filename, extension):
async def parse_submission(request):
try:
async with request.form(max_files=3, max_fields=5, max_part_size=20000) as form:
fields, uploads = {}, []
async with request.form(max_files=4, max_fields=5, max_part_size=20000) as form:
fields, uploads, diagnostic_upload = {}, [], None
for name, value in form.multi_items():
if isinstance(value, UploadFile):
if name == "diagnostics":
if diagnostic_upload is not None:
raise ApiError(422, "validation_error", "Only one diagnostic log is allowed.")
diagnostic_upload = value
continue
if name != "attachments":
raise ApiError(422, "validation_error", "Unexpected file field.")
uploads.append(value)
@@ -387,10 +393,12 @@ 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 not fields.get("school", "").strip()
elif ("message" in fields or uploads or diagnostic_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
if len(uploads) > 3:
raise ApiError(413, "upload_too_large", "Maximum three media files.")
for upload in uploads:
data = await upload.read(FILE_LIMIT + 1)
total += len(data)
@@ -401,6 +409,17 @@ async def parse_submission(request):
if not valid:
raise ApiError(415, "unsupported_media", "Attachment MIME type and file signature must match a supported image or video.")
attachments.append((safe_filename(upload.filename, extension), mime, data))
if diagnostic_upload is not None:
data = await diagnostic_upload.read(LOG_LIMIT + 1)
if len(data) > LOG_LIMIT:
raise ApiError(413, "upload_too_large", "Diagnostic log exceeds 256 KiB.")
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
raise ApiError(415, "unsupported_media", "Diagnostic log must be UTF-8 text.") from None
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))
return fields, attachments
except HTTPException as exc:
detail = str(exc.detail).lower()
+34
View File
@@ -62,6 +62,40 @@ def test_success_only_after_sender_accepted_and_retry_is_durable(service):
assert len(sender.messages) == 1
def test_diagnostic_log_is_extra_email_attachment_and_retry_stays_idempotent(service):
client, sender, _ = service
log = b'TPSchedule diagnostic log\n{"event":"login_opened"}\n'
files = [("submission_id", (None, ID)), ("type", (None, "problem")),
("message", (None, "Login stopped"))] + [
("attachments", (f"image-{i}.png", PNG, "image/png")) for i in range(3)] + [
("diagnostics", ("TPSchedule-diagnostics.txt", log, "text/plain; charset=utf-8"))]
assert client.post('/v1/feedback', files=files).status_code == 200
attachments = list(sender.messages[0].iter_attachments())
assert len(attachments) == 4
assert attachments[-1].get_filename() == 'TPSchedule-diagnostics.txt'
assert attachments[-1].get_payload(decode=True) == log
assert client.post('/v1/feedback', files=files).status_code == 200
assert len(sender.messages) == 1
@pytest.mark.parametrize('name,kind,data,mime,status', [
('diagnostics', 'problem', b'not a log', 'text/plain', 415),
('diagnostics', 'problem', b'TPSchedule diagnostic log\n\xff', 'text/plain', 415),
('diagnostics', 'problem', b'TPSchedule diagnostic log\n' + b'x' * (256 * 1024), 'text/plain', 413),
('diagnostics', 'development', b'TPSchedule diagnostic log\n', 'text/plain', 422),
], ids=['header', 'utf8', 'oversize', 'development'])
def test_invalid_diagnostics_never_send(service, name, kind, data, mime, status):
client, sender, _ = service
fields = [('submission_id', (None, ID)), ('type', (None, kind))]
if kind == 'problem':
fields += [('message', (None, 'Cannot import'))]
else:
fields += [('school', (None, 'University')), ('contact', (None, 'QQ123'))]
response = client.post('/v1/feedback', files=fields + [(name, ('log.txt', data, mime))])
assert response.status_code == status
assert not sender.messages
def test_reusing_id_with_other_content_rejected(service):
client, sender, _ = service
assert post(client).status_code == 200