fix: harden school imports and add optional feedback diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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邮件。
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user