闲聊 · 第 72 篇

NN 聊天室 —— 一个 Flask 项目的血泪修复史

从「注册卡十分钟没反应」到「图片显示成'图片'标题」,从「E2E 加密后乱码」到「彻底关掉加密全明文」——记录一个初中生自建聊天室的所有踩坑与修复。

📡 项目简介

NN 聊天室 是我在本机跑的一个小型多人即时通讯项目,UI 走的是玻璃拟态暗色风格,后端用 Flask + SocketIO 实时推送消息。前端是 jQuery + 原生 CSS,主要服务对象是我自己(有时会拉几个朋友进来测试)。

源码已开源:📦 ciallo0721-cmd/NN-Chat

名字里 NN 是取自「Neighborly Network」——其实没想那么多,就是两个字母凑着顺眼喵。

它从最早只能发文字,慢慢加上了:图片/文件上传、好友私聊、群组聊天、表情回应、消息撤回/编辑、未读红点、@ 提及、输入状态指示、最近联系人排序……反正该有的社交功能都堆了一遍。


🧩 技术栈

选型备注
后端框架FlaskPython 3.13,单文件 1800+ 行的启动器
实时通信Flask-SocketIO + eventletWebSocket 广播新消息
WSGIWaitress实际是被 monkey_patch 后的 eventlet 驱动
数据库SQLite + WAL 模式单文件 chat.db,项目目录绝对路径
前端jQuery 3.6 + Font Awesome玻璃拟态暗色 CSS 全部手写
认证SHA256 + 随机盐Flask session 维护登录态
加密已关原 E2E XOR 实现有 bug,2026-07-28 彻底关停

🕳️ 踩过的坑

故事从某天注册新账号说起——点了注册按钮之后,转了 10 分钟也没跳到聊天界面。下面是这一晚上陆续翻出来的 bug 集合:

💥
症状一:注册卡 10 分钟没反应

按钮转圈,location.reload() 也调用了,但页面死活不回聊天界面。

⚠️
症状二:图片/文件都发不了

点击发送后弹「上传失败」,看 network 才发现 Content-Type 全是 text/html。

🔐
症状三:E2E 加密后消息全乱码

关掉页面再回来,以前的聊天全是 X1QD / 5LyO5aSY 这种鬼东西,base64 也解不回来。

😂
症状四:第一个注册用户自动变 admin

我注册的「普通账户」自己被代码升成管理员——因为代码里有个「COUNT==0 自动 admin」的逻辑。


🛠️ 修复时间线

下面按时间顺序列出今晚一个一个翻出来 + 修掉的过程。每一项都是踩坑后定位到根因、验证后再继续下一个。

  1. 元凶:@app.after_requestforce_text_html
    这段代码把所有响应(包括 JSON)的 Content-Type 强制改成 text/html。jQuery 没指定 dataType 时按响应 Content-Type 推断,被骗成字符串处理,所以 data.status === "success" 永远 false。注册按钮转圈成功但不 reload。
  2. 前端 $.ajaxSetup({ dataType: 'json' })
    加这一行兜底,所有 ajax 默认按 JSON 解析,无论服务端返回什么 Content-Type。
  3. force_text_html 改成 fix_content_type
    只在响应 没有 Content-Type 时才补成 text/html,已经有 JSON/图片/文件的响应一律不动。
  4. Session 在 LAN IP 下被浏览器悄悄拦截
    我用 http://192.168.18.66:5000 访问,Chrome 似乎对私有 IP + SameSite=Lax 有特殊行为,session 写不进去。改用 127.0.0.1 访问没问题。但更稳的方案是再加 fallback cookie:注册时多写两个普通 cookie nn_uid / nn_uname/ 路由的认证改成 session → fallback cookie → DB 三层兜底。
  5. 退出登录永远退不出去
    原因:fallback cookie 没在 logout 时清掉。修法:logout 路由同时 set_cookie('nn_uid', '', max_age=0)
  6. msg_obj.image_path = file_path or None
    后端把 file_path 的值赋给 image_path,导致文件消息被前端当作图片。HTML 里 <img> 加载失败,只显示 alt 文字「图片」。修法:直接用 image_path 字段,不复用 file_path。HTTP 和 WebSocket handler 两处都改。
  7. request.get_json() 在 Content-Type 错时抛 415
    前端 $.post(url, JSON.stringify(...)) 默认 Content-Type 是 form-urlencoded,不是 JSON,后端拿到的是 None 而且抛 UNSUPPORTED MEDIA TYPE。修法:16 处全部加 silent=True
  8. escHtml 函数藏在 {% if logged_in %} 分支里
    未登录时整段 JS 不渲染,showToast 调用 escHtml 直接报 ReferenceError。修法:提到公共区域。
  9. E2E 加密的 key 同步顺序 bug
    switchToChat 切聊天时先 本地 生成 key,然后才异步 POST 到服务器。reload 后用 /get_key 取到的还是旧 key,但本地新 key 用错密钥去解密旧消息 → 乱码。修法:先 /get_key 拿服务器已有 key,拿到后再 fetchMessages
  10. 最终:直接关掉 E2E
    上面修完了但还是太麻烦——直接删掉所有 XOR 加解密,删 chat.db 和 sf/ 重新开始。消息存纯明文,reload 一万次都不乱码。

最终修复完,全链路 curl 测试一遍:注册 ✅、发文字 ✅、发图片 ✅、发文件 ✅、登录态保持 ✅、退出登录 ✅。重启服务器没报错。

成果

9 个 bug 全部修复,全部明文存储,零加密。一晚上搞定 (๑•̀ㅂ•́)و✧


📸 现场截图

下面这 3 张图是这次修复过程中关键的现场记录:

NN 聊天室双窗口测试

图 1:两个客户端(test 和 admin)同时在浏览器里跑,左边收到的「牛逼牛逼果然可以回了」就是修复后能正常发送的消息。

「您的账号已被封禁」横幅

图 2:我 ban 了左边的「12」——它一直都是普通用户。用 admin 账号 ban 普通用户,这才是正常的 ban 功能工作流程 (๑•̀ㅂ•́)و✧

登录页被封禁提示

图 3:被 ban 之后,登录页直接显示「您的账号已被封禁」红色提示,连输入框都没法用——这个机制工作得很好(虽然我是被自己 ban 的)。


🎭 彩蛋:把自己 ban 了

修复过程中顺手测试管理员后台的封号功能:注册了一个普通账号「12」,然后在后台 /admin/users 面板里 ban 掉了它。结果「12」开着浏览器没刷新,再操作时直接弹横幅「您的账号已被封禁」——封禁逻辑工作得挺好的(虽然对「12」本人不太友好)。


💭 总结感想

这次的 bug 串挺好看的——一个 force_text_html 引发的血案,带出来 Content-Type 推断、jQuery dataType、Session cookie 跨域、文件图片字段复用、HTTP 415、E2E 密钥同步…… 全是常见但绕的坑。

复盘之后最核心的教训是:不要复用语义不同的字段image_pathfile_path 一开始就分得清清楚楚就不会有这个 bug。同样地,session 状态也别只靠一个 cookie,至少给个 fallback。

还有就是「明文 vs 加密」的权衡——E2E 是个好东西,但做不出来就别做,不如老老实实存明文 + HTTPS 传输。修加密的 bug 比写明文代码多花几倍时间。

最后,IIS 占着 80 端口是今天另一个搞笑的发现:关掉 IIS 之后才能直接访问 http://localhost/,不然得加 :5000 端口号。停服务用 net stop W3SVC /yes,一招解决。