问题:脚本里只有 docker tag / docker push,**没有 git tag**,所以远程仓库 一直只有软件包、没有版本 tag —— 「某个版本对应哪个提交」只能靠翻 CHANGELOG 猜。 镜像 tag 只说明「注册表里有这个包」,回答不了「这个包是哪份代码构建的」。 tools/push-all.sh: - 新增**版本预检**(传了版本号时):从 workbuddy_portal/__init__.py 读 __version__ 与传入值比对,不一致立刻退出 —— 放在最前面,免得代码和镜像 都推完了才发现「代码写着 1.4.0、却当 1.5.0 发」 - 新增 git tag -a vX.Y.Z + 推送该 tag,**放在代码与镜像都推成功之后**: tag 一旦出现在远端就是「这个版本发过了」的公开声明,不该先于产物出现 - 已存在的 tag 只提示、不改写(指向哪个提交由历史决定) 补打历史标签:v1.2.0 / v1.3.0 / v1.4.0,各指向引入该版本号的那个提交 (1.1.0 在仓库里没有对应提交,故未补)。 文档: - DEPLOYMENT 7.1 补上「为什么必须有 git tag」与版本预检说明 - DEPLOYMENT 7.4 补上「镜像与 tag 要分别核验」的命令(git ls-remote --tags) —— 此前 7.4 只验镜像,正是这个盲区让「没有 tag」一直没被发现 验证:python tools/check_docs.py → 0 处问题;python tools/smoke.py → ok=264 fail=0
642 行
44 KiB
Markdown
642 行
44 KiB
Markdown
# 变更日志
|
||
|
||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/):`主版本.次版本.修订号`。
|
||
|
||
- **主版本**:不兼容的变更(数据库迁移需手工介入、接口契约变化)
|
||
- **次版本**:向后兼容的功能新增
|
||
- **修订号**:向后兼容的缺陷修复
|
||
|
||
---
|
||
|
||
## [未发布]
|
||
|
||
### 发布流程修复:脚本从来没打过 git tag
|
||
|
||
- **问题**:`tools/push-all.sh` 里只有 `docker tag` + `docker push`,**没有 `git tag`**。
|
||
结果是远程仓库里**只有软件包、没有版本 tag** —— 「1.5.0 对应哪个提交」只能靠翻本文件猜,
|
||
想 checkout 某个已发布版本的代码没有可靠入口。镜像 tag 只说明「注册表里有这个包」,
|
||
它回答不了「这个包是哪份代码构建的」。
|
||
- **现在脚本会做三件事**:
|
||
1. **版本预检**(传了版本号时):从 `workbuddy_portal/__init__.py` 读 `__version__`
|
||
与传入值比对,不一致**立刻退出**。放在最前面,免得代码和镜像都推完了才发现
|
||
「代码里写着 1.4.0、却当 1.5.0 发」——那种错误只能靠改 tag 补救。
|
||
2. 代码与镜像都推成功后,打 `git tag -a vX.Y.Z` 并推送该 tag。放在最后是有意的:
|
||
tag 一旦出现在远端就是「这个版本发过了」的公开声明,不该先于产物出现。
|
||
3. 已存在的 tag **只提示、不改写** —— 指向哪个提交由历史决定,不该被脚本偷偷换掉。
|
||
- **补齐历史 tag**:`v1.2.0` / `v1.3.0` / `v1.4.0` 三个注解 tag 已按各自提交的
|
||
`__version__` 补打并推送(每个都指向引入该版本号的那个提交,可用
|
||
`git for-each-ref refs/tags` 复核)。1.1.0 在仓库里没有对应提交,故未补。
|
||
- **文档**:`docs/DEPLOYMENT.md` 第 7.1 节补上「为什么必须有 git tag」与版本预检说明;
|
||
第 7.4 节补上「镜像与 tag 要分别核验」的命令(`git ls-remote --tags origin`)——
|
||
此前 7.4 只验镜像,正是这个盲区让「没有 tag」一直没被发现。
|
||
|
||
---
|
||
|
||
## [1.5.0] — 2026-09-18
|
||
|
||
**主题:按「将会被公网访问」重新审一遍界面与暴露面**
|
||
|
||
两件事:① 清掉界面里的 AI 生成残留与「AI 味」,把视觉从「霓虹渐变」改回工程控制台;
|
||
② 按「能被公网访问」的前提逐条收口信息泄漏、注入与资源滥用。
|
||
**没有库结构变更**(`user_version` 仍是 4),升级不需要手工介入。
|
||
|
||
### 安全与隐私加固(对外提供服务)
|
||
|
||
**主题:把「能被公网访问」当成前提,逐条收口信息泄漏与资源滥用**
|
||
|
||
这一轮没有新增功能,全部是收口。原则:**能把信息少给一点就少给一点,
|
||
能把权限多收一层就多收一层**。
|
||
|
||
**信息泄漏(这类问题的共同点是:平时看不出问题,踩点阶段最好用)**
|
||
|
||
- **内部异常不再直出**。`/api/collect`、`/api/maintenance/*`、`/api/backups*` 的兜底
|
||
`except Exception` 原本把 `str(e)` 原样回给客户端 —— 那里面会带绝对路径
|
||
(`/app/workbuddy_portal/...`)、SQL 语句、`sqlite3` 的报错。现在统一走
|
||
`web/api.py::_internal`:完整堆栈写进服务端日志并打一个 **8 位事件号**,
|
||
客户端只拿到事件号。既不影响「出事了去查日志」,也不送材料。
|
||
(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError` 这些**面向用户写的**
|
||
业务异常不受影响,它们的文案本来就是给用户看的。)
|
||
- **普通账号拿不到内部实现清单**。`/api/manifest`、`/api/bundle` 里的 `sources`
|
||
(表名 `usage_records`、`daily 聚合视图` …)与 `archive`(`data/usage.sqlite`)
|
||
只对管理员下发;非管理员的大屏页「数据源」卡片自动收起,避免留一个只有表头的空壳。
|
||
- **响应头注入**。导出文件名里含用户名,而用户名**并不总是**注册正则的产物
|
||
(`manage.py passwd` 建号时不校验、老库升级上来的名字也可能带引号或 CR/LF)。
|
||
一旦名字里有引号或换行,直接拼 `Content-Disposition` 就是响应拆分。
|
||
新增 `security.safe_filename()` 与 `security.content_disposition()`:
|
||
收敛成 ASCII 安全名 + 按 RFC 5987 附上原名,`/records/export` 与
|
||
`/profile/export` 都改走它。
|
||
- **路径穿越**。`collect.export_csv()` 的账号名 tag 直接拼进文件路径 ——
|
||
一个叫 `..\..\x` 的账号能把 CSV 写到数据目录之外。现在只保留 `[A-Za-z0-9._-]`,
|
||
且结果固定落在 `EXPORT_DIR` 之下。同时 `manage.py passwd` **建号时补上用户名校验**
|
||
(与注册页同一套),从根上不再产生这类名字。
|
||
- **用户名枚举的时序侧信道**。`login_ok` 在账号不存在时不执行任何哈希计算,
|
||
比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在。
|
||
现在对不存在的账号也走一次**同代价的哑哈希**,两条路径耗时对齐。
|
||
|
||
**隐私:落盘权限(这条是对外部署最容易被忽略的)**
|
||
|
||
- 数据目录里躺着的是:`usage_records.prompt`(**用户与 AI 的完整对话正文**)、
|
||
凭证密文、`instance.json`(主密钥)、`exports/*.csv`(对话正文的**明文**副本)、
|
||
`backups/*.zip`(全库 + 主密钥)。默认 `umask 022` 会把它们留成 `0644 / 0755`,
|
||
也就是**同机任何用户都能读**。
|
||
- 新增 `config.harden_process()`:在进程启动时把 `umask` 收到 `0077`,
|
||
此后新建的一切文件(含 SQLite 的 `-wal` / `-shm`)天然是 owner-only。
|
||
刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,
|
||
而漏掉的那个文件里往往正好是最新的对话正文。
|
||
- 配套 `harden_dir()` / `harden_file()` 收紧**已经存在**的目录(老版本留下的 0755)
|
||
以及导出 CSV、备份 zip 落盘后的权限。Windows 无此模型,函数直接跳过。
|
||
|
||
**资源滥用**
|
||
|
||
- **读接口限速**:登录有 IP 锁定、注册有配额、采集有最小间隔,但读接口原本**没有任何刹车** ——
|
||
一个注册账号把 F12 里的 `/api/bundle` 请求放进 `for` 循环就能持续吃 CPU 与出口带宽
|
||
(那个接口要算全量逐日聚合,还会下发最多 2 万条明细)。现在 `/api/*` 按
|
||
「账号(未登录时按来源 IP)」限速 240 次 / 60 秒,超出返回 `429`。
|
||
阈值给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到。
|
||
|
||
**其它**
|
||
|
||
- **`--debug` 不再能绑到对外地址**。Werkzeug 的交互式调试器等于任意代码执行,
|
||
而 `--host` 默认就是 `0.0.0.0`;`python manage.py serve --debug` 会直接把 RCE 挂上公网。
|
||
现在绑非回环地址直接拒绝启动,并提示本机调试的正确写法。
|
||
- 新增 `Permissions-Policy` 响应头(显式关掉地理位置 / 麦克风 / 摄像头 / 支付 / USB)。
|
||
- 新增 `413` 处理器:请求体超限时接口返回 JSON 而不是 Flask 默认 HTML 页
|
||
(前端 `r.json()` 在 HTML 上会炸,表现成「Unexpected token <」)。
|
||
- 文档:新增 [部署指南第十三节「安全与隐私基线」](DEPLOYMENT.md#十三安全与隐私基线) ——
|
||
代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。
|
||
|
||
验证:`python tools/smoke.py` **ok=264 / fail=0**(第 9 节专门覆盖上述各项:
|
||
`safe_filename` 边界、真实导出响应头不含引号/换行、每个 `except Exception` 都走 `_internal`、
|
||
读接口限速、安全响应头、大屏页已转义、普通账号拿不到数据源清单)。
|
||
|
||
### 界面去 AI 化
|
||
|
||
**主题:清掉界面里的 AI 生成残留与「AI 味」**
|
||
|
||
- **大屏页清理生成器残留**:`static/dashboard/index.html` 里 **114 处**
|
||
`data-page-node-id="…"`(页面生成工具给每个节点打的标记)全部删除,
|
||
文件从 64 KB 降到 58 KB。这类属性没有任何运行时作用,只是生成痕迹。
|
||
- **视觉系统重做**(`static/css/app.css` + 大屏内联样式):由「深色底 + 霓虹渐变 + 辉光」
|
||
改为中性的工程控制台风格 —— 灰阶打底、单一蓝色强调色。具体删掉了:
|
||
`radial-gradient` 页面背景、按钮/激活态/分页/徽标的 `linear-gradient`、
|
||
`box-shadow` 发光(品牌点、主按钮、日历格)、渐变文字(错误页大号状态码)、
|
||
以及标题前的彩色装饰条(`.pagehead h1::before`、`.card h2::before`、`h1::before`)。
|
||
- **KPI 改用灰度分层**:原来每张卡一条彩虹色左色条,现在 `--c` 只渲染成一个 8px
|
||
状态点(`采集健康`/`自动备份` 这类状态卡仍能一眼区分),其余靠灰度与字重。
|
||
- **图表配色同步降饱和**:ECharts `PALETTE`、日历 5 级色阶、热力图 `visualMap`
|
||
与后台共用同一套语义色;数据系列改用蓝色阶 + 1 个琥珀色对比项。
|
||
- **文案精简**:删掉说教式长段落(`配置管理` 页整张「为什么采集参数是只读的」说明卡)、
|
||
把只给结论即可的提示压成一句话;`任务管理`/`配置管理`/`备份管理`/`用户管理`
|
||
的长提示同步收敛。
|
||
- **修掉两个真实缺陷**(都在这次清理中暴露):
|
||
- `templates/profile.html` 的 Markdown 星号漏进 HTML —— `**任何时候都不回传明文**`
|
||
会在页面上原样渲染成星号;
|
||
- 登录页写着「首次部署默认账号 `admin` / `admin123`」,而 1.4.0 起已改用随机口令,
|
||
该提示会把人引向一个永远登不上的口令,已删除。
|
||
- **文档同步**:`docs/USER-GUIDE.md` 中引用上述被删文案的两处说明一并更新。
|
||
界面截图 `docs/images/*.png` 仍是旧观感,需要用 `python tools/shots.py` 重出。
|
||
|
||
验证:`python tools/smoke.py` 全绿(ok=233 / fail=0),其中
|
||
「页面 class 与 app.css 选择器对账」确认新版样式表没有漏掉任何模板在用的类名;
|
||
`python tools/check_docs.py` 0 处问题。
|
||
|
||
---
|
||
|
||
## [1.4.0] — 2026-09-16
|
||
|
||
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
|
||
|
||
三件事:① 补齐「备份 / 恢复 / 导出本人数据」这条数据安全链路;② 修掉三类 P0 与
|
||
一批 P1;③ 让这个程序可以**安全地暴露到公网**——资源在容器层限制、任务频率在实例层
|
||
限制、采集跨度有硬上限。
|
||
|
||
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 3 → 4 时自动迁移,
|
||
只做一件事(`ALTER TABLE users ADD session_ver`,非空、默认 0)+建一张新表
|
||
`backups`。**无数据搬运、无键位变动**,可重复执行。
|
||
|
||
---
|
||
|
||
### 备份管理(新功能)
|
||
|
||
- **`workbuddy_portal/backup.py`(新模块)** —— 备份的全部逻辑
|
||
- **快照走 SQLite 在线备份 API**(`Connection.backup`),不是 `cp`。
|
||
它按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
|
||
手工 `cp data/usage.sqlite` 做不到这点:`-wal` 里可能还有没落盘的帧,
|
||
拷出来的库会缺最近一段数据,而且**不报错**。
|
||
- **归档 = 一个 zip**:所有 `data/*.sqlite` + `manifest.json` + `instance.json`。
|
||
单文件、可校验、可搬到别的机器恢复。
|
||
- **`backup_keep` 保留份数**:超出后按时间删最旧的(只按**磁盘上真实存在**的算份数,
|
||
已经被手工删掉的条目不占名额)。
|
||
- **`backups` 表 + 磁盘双向对齐**:`sync_index()` 把磁盘上的归档登记进表、
|
||
把消失的标记 `missing=1`(不删行,保留「这里曾有过一份」的痕迹)。
|
||
**磁盘是事实来源**,表只是缓存 —— 手工拷进来/手工删掉都能被正确呈现。
|
||
- **管理员页面 `/backups`**(导航在「用户管理」前):KPI(份数 / 占用 / 自动备份状态 /
|
||
上次与下次)、自动备份设置表单、归档内容说明、备份列表(下载 / 恢复 / 删除)。
|
||
- **接口**(全部仅管理员):
|
||
`GET /api/backups`、`POST /api/backups`、`GET /api/backups/<filename>`(下载)、
|
||
`POST /api/backups/<filename>/restore`、`POST /api/backups/<filename>/delete`、
|
||
`POST /api/backups/prune`。
|
||
- **恢复的三条设计决定**(每条都对应一个会真的踩到的坑)
|
||
1. **SQL 级整表替换,不做文件 swap**。解包 → 临时库先迁移到当前 schema →
|
||
一个 `BEGIN IMMEDIATE` 事务里整表搬过去。好处:不需要停机、不依赖「没有别人
|
||
持有文件句柄」(Windows 上文件 swap 会因句柄占用直接失败)、备份是旧版本
|
||
(uv=2/3)也能恢复、中途失败就是一次回滚。
|
||
2. **列名交集而非 `SELECT *`**。老库的列是 `ALTER` 追加的,顺序与新库建表语句
|
||
不一定一致 —— `SELECT *` 会**静默错位**,字段整体串位而值都「合法」,
|
||
是最难查的一类数据损坏。
|
||
3. **恢复前自动打一份 `pre-restore` 快照**。恢复错了还能回到恢复之前。
|
||
- **恢复后让所有会话失效**:把所有账号的 `session_ver` +1。光靠「从归档里搬
|
||
`session_ver`」是不够的 —— 在打备份**之前**登录的那个人,他那张 Cookie 里的 `sv`
|
||
正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被
|
||
这次恢复整个换掉了。
|
||
- **`instance.json` 在归档里是必要的,不是顺手加的**:没有 `cookie_key` 就永远
|
||
解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
|
||
代价是归档本身含密钥 ⇒ 它**永不入库、永不进镜像**(见下方 P0-3)。
|
||
恢复时可以 `include_instance=false` 只搬数据、保留本机当前密钥。
|
||
- **`safe_name()` 路径穿越收口**:下载与恢复接口都直接吃文件名,
|
||
这里做 basename 收敛 + 后缀 + 字符白名单(实测拦住 `../../etc/passwd`、
|
||
`x.txt`、`''`、`..`、`a b.zip`)。
|
||
- **`_extract_safely()` 防 zip slip**:归档可能是**外部给的**,
|
||
`extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外。
|
||
- **普通用户导出本人全部数据**:`GET /profile/export`(任何登录用户,10 秒限速),
|
||
用 `SpooledTemporaryFile(max_size=16MB)` 边生成边下发,含 4 份 CSV/JSON
|
||
(使用记录 / 采集历史 / 操作审计 / 我的账号与配置)+ 说明。
|
||
**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。
|
||
- **CLI**:`manage.py backup [--note]` / `backups [--prune] [--keep N]` /
|
||
`restore <文件名> --yes`(破坏性操作必须显式 `--yes`,不加只打印将要发生什么)。
|
||
- **自动备份**:`backup_enabled` / `backup_interval_hours` / `backup_keep` 三个实例级键。
|
||
由 `scheduler.tick()` 每 20 秒检查一次(**有采集在跑就跳过**,不跟采集抢磁盘),
|
||
到期就打一份并按份数清理。首次部署无需等待 —— 库里没有自动备份记录时第一轮 tick 即触发。
|
||
|
||
### P0 修复
|
||
|
||
- **P0-1 `X-Forwarded-For` 可伪造 → 三道 IP 防线全废**
|
||
- 实测:修改前每次换一个伪造的 `X-Forwarded-For`,45 次验证码请求**全部放行**;
|
||
验证码限速、注册配额、登录锁定三道防线同时失效。
|
||
- 新增 **`security.client_ip()`** —— 全站**唯一**取客户端地址的入口。
|
||
默认**不信任** XFF,直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP
|
||
(最近的一跳由你自己的代理写入,客户端伪造不了),含 `IPv4:port` 与 IPv6 处理。
|
||
- 原来散落在 `views.py` / `api.py` 的 6 处 `request.remote_addr` 全部改走它。
|
||
- **P0-2 默认口令 `admin123` 硬编码兜底**
|
||
- 现在 `WB_ADMIN_PASSWORD` 为空时,程序用 `secrets.token_urlsafe(12)` 生成随机口令,
|
||
**只在启动日志里打印一次**,且**不写进数据库**(审计日志里出现口令等于永久留档)。
|
||
`manage.py init` 会用醒目的方框打印它 —— 库里只有散列,日志一滚就再也拿不回来。
|
||
- `docker/entrypoint.sh` 里那句「默认密码是 admin123」的提示同时删除(它会给出一个
|
||
永远登录不上的口令)。
|
||
- **P0-3 `.dockerignore` 漏了 `backups/` → 凭证密文被打进镜像**
|
||
- `backups/` 里躺着 `usage.sqlite.bak-pre-v13`(4.5 MB 的**真实生产库**快照),
|
||
含 `settings` 的凭证密文与 `users` 的口令散列。而 `.dockerignore` 里没有任何
|
||
规则能匹配 `backups/` ⇒ `docker build` 会把它原样烤进镜像层,
|
||
**推一次镜像等于把整库密钥分发给所有能拉镜像的人**。
|
||
- 补 `backups/` 与 `data/demo/`;并在 Dockerfile 里加一条**构建期断言**:
|
||
`COPY` 之后若 `/app/backups` 非空就直接构建失败。
|
||
靠「记得改 .dockerignore」不可靠 —— 让构建自己拒绝。
|
||
注意这不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层。
|
||
|
||
### P1 修复
|
||
|
||
- **采集与导出没有任何跨度上限与频率限制**:一个注册账号就能反复打 `/api/collect`
|
||
把云端与线程池占满。现在 `/api/collect` 有**三道闸门**:
|
||
① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);
|
||
② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);
|
||
③ 跨度超过上限 → `400`。
|
||
- **`COLLECT_MAX_RANGE_DAYS_HARD = 31` / `SCHEDULE_SLOTS_HARD_MAX = 12`**:
|
||
代码层**硬顶**。`collect_max_range_days` / `max_schedule_slots_per_day` 只是更严的
|
||
旋钮,**改大也突破不了** —— 上限必须由代码兜底,不能只靠写入校验。
|
||
历史脏数据、手工改库都过不去。(用户要求:「最长跨度的为 1 个月」)
|
||
- **`action_allowed(key, min_interval)` 通用重操作限速**:采集 / 导出 / 导出本人数据 /
|
||
`vacuum` / 补全 prompt / 重算 各有最小间隔(`{"fill-prompt":60, "export-csv":15,
|
||
"vacuum":120, "recount":5}`,采集与 CSV 导出见各自常量)。
|
||
- **`WB_COOKIE_SECURE` 默认 0、无 HSTS**:`_env_flag()` 统一解析布尔环境变量;
|
||
`apply_security_headers()` 在 **`COOKIE_SECURE or FORCE_HTTPS`** 时下发 HSTS
|
||
(**只在真正走 HTTPS 时下发** —— 纯 HTTP 部署下发会让浏览器强升 https,
|
||
表现成白屏,是个很难归因的故障)。
|
||
- **账号锁定可以被当武器**:知道用户名就能把对方锁死 10 分钟,而**管理员用户名在导航栏里
|
||
是公开的**。改成双维度、强度刻意不同:
|
||
IP 维度真锁(`MAX_LOGIN_FAILS` + `LOGIN_LOCK_MINUTES`),
|
||
用户名维度只做秒级递增退避(`USER_SOFT_THRESHOLD` / `USER_SOFT_CAP_SECONDS=60`)。
|
||
另外加**单 IP 登录尝试总量**(`LOGIN_ATTEMPTS_PER_IP=40` / `LOGIN_ATTEMPTS_WINDOW=300`,
|
||
**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
|
||
登录失败提示也从「剩余 N 次」改成「本来源连续失败 N 次」—— 不再给攻击者倒计时。
|
||
- **改密码不失效其他会话**:新增 `users.session_ver`(`DB_SCHEMA_VERSION` 3 → 4),
|
||
`current_user()` 每个请求把会话里的 `sv` 与库里比对,不等就丢会话。
|
||
改自己密码时会**把当前会话刷新到新版本**(否则改完立刻被自己踢下线)。
|
||
管理员重置口令 / 停用账号 / 删除账号同样 `bump_session_ver()` —— 停用立即生效,
|
||
不用等会话过期。
|
||
- **验证码强度不足**(字模在源码里、只整体放大 5 倍、无扭曲,容易被模板匹配):
|
||
改为让**同一字符两次渲染尽量不同** —— 逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、
|
||
波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。
|
||
实测同一验证码两次渲染字节差异 **76.9%**,字符仍可辨认。
|
||
- **`instance.json` 未设权限**:POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,
|
||
同机其他用户可读)。恢复时写回也走同一处理。
|
||
- **无访问日志**:waitress 自己不记 access log,出事无据可查。
|
||
新增 `security._access_log()`(跳过 `/static/` 与 `/captcha.png`,写进 `logs/app.log`),
|
||
由 `WB_ACCESS_LOG` 控制(默认开)。
|
||
- **`api_base` 可指向云元数据地址**:拦截 `169.254.169.254` /
|
||
`metadata.google.internal` / `[fd00:ec2::254]` —— 这是 SSRF 拿云上临时凭证最经典的一跳,
|
||
而没有任何合法采集场景需要它。
|
||
- **口令黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)。
|
||
只在**设置/修改**口令时校验,登录不校验 —— 否则会把用老口令的存量用户挡在门外。
|
||
|
||
### 新增(配置项)
|
||
|
||
`GLOBAL_KEYS` 17 → **23** 键。新增的 6 个都是实例级:
|
||
|
||
| 键 | 默认 | 范围 | 说明 |
|
||
|---|---|---|---|
|
||
| `max_schedule_slots_per_day` | 6 | 1~12 | 每日调度时刻数上限(挡住「填 200 个时刻」) |
|
||
| `collect_min_interval_seconds` | 60 | 0~3600 | 同一账号两次手动采集的最小间隔 |
|
||
| `collect_max_range_days` | 31 | 1~31 | 单次采集的最长跨度(硬顶 31 天 = 1 个月) |
|
||
| `backup_enabled` | 1 | 0/1 | 是否开启自动备份 |
|
||
| `backup_interval_hours` | 24 | 1~720 | 备份周期 |
|
||
| `backup_keep` | 7 | 1~100 | 保留最近几份 |
|
||
|
||
新增环境变量:`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、`WB_THREADS`、
|
||
`WB_BACKUP_DIR`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT`(compose 用)、
|
||
`WB_HOST_BACKUP_DIR`(叠加层用)。
|
||
|
||
### 部署与外网暴露
|
||
|
||
- **`BACKUP_DIR` 刻意不在 `data/` 里面**:容器里 `data/` 是数据卷,
|
||
`docker compose down -v` 会把正本与副本一起删 —— 那正好是最需要备份的时刻。
|
||
新增 `WB_BACKUP_DIR`(容器里 `/app/backups`)+ compose **命名卷 `wb_backups`**。
|
||
**绝不退回绑定挂载**(Windows 9p 下容器会 `unable to open database file`)。
|
||
- **容器资源上限**(用户要求「资源从容器上限制」):
|
||
`cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` 与 `mem_limit` **相等**(= 禁用 swap,
|
||
这样超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/
|
||
`pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`
|
||
(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库)。
|
||
- **`manage.py serve` 的线程数改为 `config.THREADS`**(原为硬编码 8):
|
||
它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复),是资源上限的一部分。
|
||
1 核配 8 线程容易出现「都在等 CPU」的假并发,容器默认给 4。
|
||
- 新增 `docker-compose.yml` 里 `WB_TRUST_PROXY` / `WB_FORCE_HTTPS` /
|
||
`WB_COOKIE_SECURE` 三者相邻并写明「必须一起决定」—— 拆开写很容易出现
|
||
「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||
|
||
### 其它
|
||
|
||
- `scheduler.py` 的 `tick()` 末尾增加自动备份检查(在账号循环**之外**,因为它是
|
||
实例级、与具体账号无关)。
|
||
- `collect.py` 新增 `max_range_days(conn)` / `min_interval_seconds(conn)`:
|
||
接口、页面、`sync()` 三处共用**唯一口径**(此前会出现「页面显示的限值」与
|
||
「接口实际校验的限值」不是同一个数的情况)。
|
||
- `sync()` 因跨度上限收窄起点时打一条 `[warn] 请求跨度超过上限 %d 天,已自动收窄起点`。
|
||
- `backup.sync_index()` 现在从 manifest 里读真实的 `trigger` / `actor` / `note`,
|
||
不再一律标成 `external` —— 恢复前最需要判断的恰恰是「这份是自动备份、
|
||
还是我手工留的、还是恢复前系统自动存的那一份」。
|
||
- **新增页**:`/backups`(仅管理员)、`/profile/export`(任何登录用户)。
|
||
- **测试**:`tools/smoke.py` 与 `tools/check_live.py` 补备份链路、越权、
|
||
跨度上限、并发拒绝、会话失效断言。
|
||
|
||
---
|
||
|
||
## [1.3.0] — 2026-09-16
|
||
|
||
**主题:权限收敛 · 配置作用域统一 · 目录规范化**
|
||
|
||
把「配置存在哪一级」与「谁能改它」对齐成一条规则,并收紧普通账号的越权面。
|
||
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 2 → 3 时自动迁移,
|
||
把管理员个人名下的调度与采集参数**提升到实例级**(`user_id=0`)后清掉个人残留,
|
||
全程写一条 `promote_global_settings` 审计,且可重复执行。
|
||
|
||
### 变更(**不兼容**)
|
||
|
||
- **调度与采集参数改为实例级,普通账号只读**
|
||
- `GLOBAL_KEYS` 扩容:`api_base` / `api_path` / 注册策略 4 键
|
||
+ `schedule_enabled` / `schedule_times` / `catch_up` / `catch_up_grace_hours`
|
||
+ `page_size` / `rewind_minutes` / `drift_tolerance_minutes` / `max_prompt` /
|
||
`verify_days` / `timeout` / `ssl_verify`
|
||
- 新增 `config.USER_EDITABLE_KEYS = {cookie, user_agent}` —— 普通账号**唯一**可写的两个键
|
||
- 新增 `config.writable_by(key, is_admin)`:前后端与测试共用的**唯一**判断入口,
|
||
避免「页面置灰但接口还能写」这类规则漂移
|
||
- 为什么必须放实例级(而不是「个人级但只有管理员能写」):若只写在管理员自己的
|
||
`user_id` 下,其它账号读取时会回落到 `DEFAULTS`,**管理员改的值对别人完全不生效**
|
||
—— 那是个静默 bug。统一放实例级,语义是「一台部署一套采集与调度策略」。
|
||
- **`set_setting()` 强制把全局键重定向到 `user_id=0`**:从结构上消除
|
||
「管理员改了只有自己生效」的可能
|
||
- **`/logs` 与 `/logs/tail` 改为仅管理员**(原先普通账号能看到自己的采集历史 +
|
||
整机应用日志尾部)。普通账号访问返回 403,导航里不显示入口
|
||
|
||
### 新增
|
||
|
||
- **`manage.py init` 自动迁移(uv 2 → 3)**:`_promote_personal_to_global(conn)`
|
||
取首个管理员的个人级全局键值提升到 `user_id=0`,再清除 `user_id<>0` 的残留;
|
||
幂等,可反复执行
|
||
- **凭证类键刻意不灌实例级**:`init_db()` 灌默认值时 `continue` 掉
|
||
`USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')`
|
||
—— 实例级存凭证等于给所有账号发同一张身份
|
||
- **`/api/settings` 回传 `_userKeys` / `_role`**,`/api/manifest` 回传 `role`,
|
||
`/api/status` 新增 `is_admin` / `can_edit_schedule` / `can_view_logs`
|
||
—— 大屏是静态页,拿不到 Jinja 上下文,只能靠这几个字段决定显隐
|
||
- **`app.js:formData()` 跳过 disabled 控件**(含祖先 `fieldset[disabled]`):
|
||
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」
|
||
拒掉**整单**;现在只读项既不显示也不参与提交
|
||
- **测试**:`tools/smoke.py` 162 → 215 项断言(新增「非管理员越权面必须全部关死」
|
||
与「全局键必须落在实例级」两节);`tools/check_live.py` 83 → 122 项,
|
||
新增 `--as USER:PASS` 参数与第 12 节「普通账号真实 HTTP 越权验收」
|
||
- **新增 `tools/check_docs.py`(文档自检)**:内部链接与**跨文件锚点**、图片引用、
|
||
**绝对路径泄漏**(连带会泄漏用户名)、版本一致性(`__init__` / `Dockerfile` /
|
||
`README` / `CHANGELOG` 四处)、模板与 JS 里的产品名硬编码。
|
||
文档互相引用后章节一重排,锚点会**静默失效** —— Markdown 自己不报错、CI 也不管,
|
||
只能靠这一层。**有问题即退出码 1**(只想看报告不失败用 `--no-fail`)。
|
||
文档清单**自动发现**,不写死文件名 —— 写死列表的那版曾漏掉
|
||
`THIRD-PARTY-NOTICES.md` / `CODE_OF_CONDUCT.md`(覆盖面 13 → 9 个文件且毫无提示)。
|
||
锚点比对**刻意不逐字复刻 GitHub/Gitea 的 slug 算法**(各家对 `+`/`:`/连续空格的
|
||
处理并不一致,写死一个实现换个托管平台就批量误报),改为只比较「有效字符」
|
||
(小写字母 / 数字 / 汉字),既不受标点差异干扰,章节真被改名时又照抓不误
|
||
|
||
### 修复
|
||
|
||
- **`api_status` 引用了未定义的变量 `u`** ⇒ `/api/status` 稳定 500。
|
||
该缺陷由 `check_live.py` 新增的真实 HTTP 验收抓到,此前 smoke 完全没有覆盖这个接口
|
||
- **`tools/smoke.py` 的清理语句会把实例级配置当孤儿删掉**
|
||
(`DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`,
|
||
而 `user_id=0` 不是任何真实账号)⇒ 每跑一轮 smoke 就清空一次实例级配置。
|
||
实测曾把 19 个实例级键清到只剩 1 行。已加 `user_id<>0 AND` 并补防回归断言
|
||
- **`tools/smoke.py` 哨兵 UA 还原会留下多余行**:原本实例级无 `user_agent` 行时
|
||
`set_setting(..., "")` 会插一行空串。改为「原本无则 DELETE」,断言也改成比行为而非比行
|
||
|
||
### 目录规范化
|
||
|
||
- 工作区根目录的 v1.0 单文件版(`fetch_usage.py`、`dashboard/`、`data/usage_records.csv`)
|
||
收进工作区级 `legacy-v1/`,附带 README 说明「已被取代、可安全删除」;
|
||
`config.LEGACY_CSV_CANDIDATES` 第一候选同步指向新位置
|
||
- 新增 `backups/` 作为数据库快照的统一落点(刻意**不放在 `data/`**——
|
||
`data/` 是 Docker 卷,`down -v` 会把备份和正本一起删掉)
|
||
- `.gitignore` 补 `backups/*`、`data/*.bak*`、`legacy-v1/` 三条兜底规则
|
||
- 清理 `data/shots/`(已被 `docs/images/` 取代)与全部 `__pycache__`
|
||
|
||
### 文档
|
||
|
||
- `docs/DEPLOYMENT.md` 重写:三条并列的部署路径(裸机 / Docker 自打包 /
|
||
docker-compose 拉云端镜像),配置项速查表按新作用域重排
|
||
- `docs/USER-GUIDE.md` 重写:新增「权限与数据边界」「信息安全与隐私安全」两章,
|
||
截图重出为普通账号视角
|
||
- `README.md` / `SECURITY.md` / `CONTRIBUTING.md` / `docs/ARCHITECTURE.md` /
|
||
`docs/API.md` / `docs/FAQ.md` 同步权限模型、配置作用域与验证层变化;
|
||
订正了 CONTRIBUTING / ARCHITECTURE / FAQ 里残留的旧断言数(165/83 → 215/122)
|
||
|
||
---
|
||
|
||
## [1.2.0] — 2026-09-15
|
||
|
||
**主题:多用户化 · Cookie 加密 · 开放注册与验证码**
|
||
|
||
从单用户版升级到多用户版。**数据不会丢**:`manage.py init` 会自动检测旧表结构并迁移
|
||
(`PRAGMA user_version` 0 → 2),历史用量归到首个账号、明文 Cookie 就地加密,
|
||
全程写一条 `schema_migrate` / `encrypt_secrets` 审计,且可重复执行。
|
||
|
||
### 新增
|
||
|
||
- **多用户与数据隔离**
|
||
- `users` 表补齐 `email` / `status` / `register_ip` / `last_login_ip`;
|
||
`settings` 主键改为 `(user_id, key)`,`usage_records` 改为 `(user_id, request_id)`,
|
||
全部索引以 `user_id` 打头;`collect_runs` / `audit_log` 增加 `user_id`
|
||
- `query.py` / `collect.py` / `scheduler.py` 全链路把 `uid` 作为 `conn` 之后的
|
||
**第一个位置参数且无默认值** —— 漏传直接 `TypeError`,不会退化成「返回全量」
|
||
- 配置三级回落:`个人 → 实例(user_id=0) → config.DEFAULTS`;
|
||
新增 `NO_FALLBACK_KEYS = {cookie, user_agent}`,凭证**永不回落**(回落即串号越权)
|
||
- `scheduler.tick()` 遍历启用账号逐个判断槽位;未配 Cookie 的账号自动跳过
|
||
- 新增 `manage.py users` / `stats -u` / `status`(逐账号)/ 各子命令的 `-u/--user`
|
||
- **Cookie 静态加密**(`workbuddy_portal/crypto.py`,约 190 行,**零第三方依赖**)
|
||
- 手写 ChaCha20 块函数(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,
|
||
密文格式 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`,已用官方测试向量逐字节验证
|
||
- 主密钥 `cookie_key` 独立存放在 `data/instance.json`(与 `SECRET_KEY` 分开键位)
|
||
- `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把加密键一律置空;
|
||
`db.secret_state()` 只回 `{set, chars, tail, broken}`,绝不含明文
|
||
- `decrypt()` 对非 `v1.` 前缀原样返回(兼容历史明文,下次写入自动升级),
|
||
校验失败**抛异常**而不是「失败就返回原值」;升级时自动把历史明文加密
|
||
- **开放注册**:`/register` 页 + `POST /api/users`(管理员);开关 `allow_register`、
|
||
同 IP 每日配额 `register_max_per_ip`;用户名/密码强度校验(保留字黑名单、≥8 位且两类字符)
|
||
- **图形验证码**(`workbuddy_portal/captcha.py`,约 250 行,零第三方依赖)
|
||
- **手写 PNG 编码器**(zlib 压缩 IDAT)+ 5×7 点阵字模 + Bresenham 干扰线与噪点。
|
||
刻意不用 SVG —— SVG 是文本,答案会明文出现在页面源码里
|
||
- 答案只写服务端 `captchas` 表;会话里仅存随机 id;**一次性、5 分钟过期、按用途隔离**
|
||
- 策略 `captcha_policy`:`always`(默认)/ `adaptive`(同来源失败 2 次后要求)/ `off`
|
||
- 登录**先验验证码再比口令**(否则攻击者能拿「密码对不对」当信号提前跑完字典)
|
||
- **安全加固**
|
||
- 失败限速改为 **IP + 用户名双维度**,任一超限即锁;新增验证码出图限速(60s/40 张)
|
||
- `current_user()` 每请求回查 `users.status` ⇒ 停用账号**立即**失效,不必等会话过期
|
||
- 安全响应头:CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` / COOP;
|
||
`/api/*` 与 `/captcha*` 带 `no-store`
|
||
- 会话 cookie 显式 `HttpOnly` + `SameSite=Lax` + `Path=/`;`WB_COOKIE_SECURE=1` 可开 Secure
|
||
- `/logs/tail` 改为**仅管理员**;管理员不能停用/降权/删除自己
|
||
- **页面**:新增 `/login` 验证码、`/register`、`/profile`(个人中心,点右上角用户名进入);
|
||
`/config` 增加凭证状态与 `cookie_broken` 告警、实例级设置区;`/users` 增加邮箱/状态列与启停
|
||
|
||
### 变更
|
||
|
||
- 接口新增:`GET/POST /api/profile`、`POST /api/captcha`(机制自述)、
|
||
`POST /api/users/<id>/delete`;`GET /api/settings` 回传 `_globalKeys` / `_canEditGlobal`
|
||
- `/api/collect` 未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken`
|
||
(不再静默当成「未配置」)
|
||
- `/records/export` 与 CLI `export-csv` 的默认文件名带账号名(多用户下同名会互相覆盖)
|
||
- `WB_COOKIE` 环境变量兜底**已移除** —— 它会导致串号
|
||
|
||
### 修复
|
||
|
||
- `db.get_db()` 在流式响应里被复用导致 `Cannot operate on a closed database`
|
||
(生成器内部改为自建连接)
|
||
- `.dockerignore` 的 `__pycache__/` 只匹配上下文根目录,嵌套目录会被打进镜像
|
||
- 注册成功提示与注册页说明里的 `**强调**` 字面量(HTML 不解析 Markdown)
|
||
- **`base.html` 顶层的 `{% set me = current_user() %}` 会覆盖子模板传入的同名变量**
|
||
—— 而 `current_user()` 只含 `id/username/display_name/is_admin`,于是个人中心把
|
||
`me.created_at` 渲染成空(「注册于 ·」)。局部变量改名 `cur`,`smoke.py` 加 3 条防回归断言
|
||
- `WB_COOKIE_SECURE` 没有写进 `docker-compose.yml` 的 `environment:`
|
||
—— 在 `.env` 里设了也不生效,文档里的开关实际是哑的(已补上,并加进 `.env.example`)
|
||
- 「配置管理 → 修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
|
||
- 「用户管理」删除说明写「可勾选保留」,而页面只有确认框、必删数据,措辞改为与实际一致
|
||
|
||
### 升级提示
|
||
|
||
- 纯 HTTP 局域网部署**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie,
|
||
表现为「刚登录完又被弹回登录页」
|
||
- 迁移后请到「配置管理」确认 Cookie 状态;`secret_state.broken = true` 说明
|
||
`data/instance.json` 里的 `cookie_key` 与写入时不一致,重新粘贴一次即可
|
||
|
||
---
|
||
|
||
## [1.1.0] — 2026-09-14
|
||
|
||
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
|
||
|
||
### 新增
|
||
|
||
- **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、
|
||
`docker-compose.yml`(单服务、**数据放 Docker 命名卷**、健康检查、日志轮转)、
|
||
`docker-compose.hostdir.yml`(可选叠加层:把数据/日志放到宿主机目录,仅建议 Linux)、
|
||
`docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、
|
||
`.dockerignore`、`.env.example`
|
||
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
|
||
`WB_ADMIN_USER` `WB_ADMIN_PASSWORD` `WB_DISABLE_SCHEDULER` `WB_IMPORT_CREDS` `WB_IMPORT_XLSX`
|
||
- **文档体系** `docs/`:
|
||
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图,**全部用合成示例数据渲染**)、
|
||
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
|
||
[架构与设计说明](ARCHITECTURE.md)、
|
||
[接口参考](API.md)、
|
||
[常见问题](FAQ.md)
|
||
- **开源声明体系**(仓库根目录):[LICENSE](../LICENSE)(MIT)、
|
||
[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)(依赖清单与再分发合规自查)、
|
||
[CONTRIBUTING.md](../CONTRIBUTING.md)(含「必须遵守的不变量」与五层自检方法)、
|
||
[SECURITY.md](../SECURITY.md)、[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)、
|
||
`.github/` 下的 Issue 表单与 PR 模板、`.editorconfig`,
|
||
以及给全部 Python / Shell 源文件加 `SPDX-License-Identifier: MIT` 头
|
||
- **`tools/demo_data.py`**:生成**完全合成**的示例库(假模型名 / 假 Prompt / 偏斜的积分分布),
|
||
写入 `data/demo/`(已在 `.gitignore` 内)。文档截图与本地调试都基于它,
|
||
任何人不需要真实账号就能复现整套界面
|
||
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
|
||
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
|
||
|
||
### 变更
|
||
|
||
- **文档数据脱敏**:9 张界面截图全部改用合成示例数据重拍;
|
||
`docs/API.md`、`docs/DEPLOYMENT.md` 示例响应里的真实模型名与真实统计数字一并替换为示例口径。
|
||
此前截图中含**真实 Prompt 全文**、本机路径与本机用户名,属于不该公开的内容
|
||
- **`.gitignore` 补强**:只写 `data/*.sqlite` 会漏掉子目录,改为同时保留 `data/**/*.sqlite`
|
||
等规则,并新增 `data/demo/` 忽略——否则 `tools/demo_data.py` 的产物会被误提交
|
||
|
||
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
|
||
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
|
||
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
|
||
- **界面品牌**统一为 **WorkBuddy Portal**(此前为「WorkBuddy 用量门户」)
|
||
- 项目标识收敛到 `config.PROJECT_NAME` / `PROJECT_TITLE` / `PROJECT_DESC` 单一来源,
|
||
模板通过 `app_name` 等上下文变量引用,不再多处硬编码
|
||
- 数据 / 日志目录支持环境变量覆盖(`WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB`)
|
||
- 版本号 1.0.0 → **1.1.0**
|
||
- 大屏页标题改为「WorkBuddy Portal · 积分消耗大屏」
|
||
|
||
### 修复
|
||
|
||
| # | 症状 | 根因 |
|
||
|---|---|---|
|
||
| 1 | `/records/export` **必然 500** | 生成器在请求上下文销毁后才被迭代,复用 `db.get_db()` 撞「数据库已关闭」。改为生成器内自建连接 |
|
||
| 2 | **大屏页图表全白** | `/dashboard` 无尾斜杠,`src="vendor/echarts.min.js"` 被解析成 `/vendor/…` → 404 |
|
||
| 3 | `/users` 500 | 路由已注册但 `users.html` 不存在 |
|
||
| 4 | 明细页日期筛选失效 | 视图传 `f.frm`、模板读 `f.from`;导出链接拼 `?frm=` 而接口只认 `from` |
|
||
| 5 | 配置页 3 个维护按钮全死 | 模板调 `WBU.bindMaint()`,`app.js` 里没有该函数 |
|
||
| 6 | 审计只能看最近 40 条 | `LIMIT 40` 写死 |
|
||
| 7 | 明细页多跑一条无用 `SELECT` | `day_list()` 取了没人用 |
|
||
| 8 | 登录页锁定阈值写死 | 未从配置注入 |
|
||
|
||
> **#2 值得单独一提**:前两层测试都没抓到(离线断言只看状态码 + `<html>`,
|
||
> 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。
|
||
> 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。
|
||
|
||
另外在容器实测中发现并修掉一个**部署期才暴露**的问题:
|
||
|
||
| 症状 | 根因 | 处理 |
|
||
|---|---|---|
|
||
| 容器跑着跑着页面全 500,日志里 `sqlite3.OperationalError: unable to open database file` | 数据原本用**绑定挂载**;Windows + Docker Desktop 走 **9p**,宿主的 Windows 进程只要访问过这个 WAL 库(**纯读也会触发**),容器侧下一次连接就重建不了 `-shm`,且**不会自愈** | 改为 **Docker 命名卷**(容器独占数据目录);需要宿主目录时叠加 `docker-compose.hostdir.yml`(仅建议 Linux) |
|
||
|
||
最小复现:容器正常 → 宿主跑一次 `manage.py stats` → 容器立刻打不开库、且重启前不再恢复。
|
||
已写进 [DEPLOYMENT.md 第十一节](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
|
||
|
||
### 安全
|
||
|
||
- 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、
|
||
`/\evil.com`、绝对地址与含 CR/LF 的值
|
||
- 缺 CSRF 的写请求统一 400(此前部分路径漏检)
|
||
- 默认**开启**云端 HTTPS 证书校验(`ssl_verify=1`)。Cookie 是账号凭证,不该裸奔
|
||
- 登录失败计数表加上限(4096 个 IP)与 TTL(1 小时),防内存被大量来源 IP 撑爆
|
||
- `/logout` 拆分为 POST(执行)+ GET(仅提示),防 `<img src="/logout">` 静默退出
|
||
- `settings` 的内部簿记键(`slot:*`)读写两侧都过滤,不再从 `/api/settings` 泄漏
|
||
|
||
### 内部质量
|
||
|
||
- 设置项**写时校验 + 读时兜底**:`config.normalize_setting()`(范围 + 单位,非法值 400
|
||
并列出全部错误)+ 采集路径全面改用 `db.get_int()`,杜绝「一个手滑的数字让采集整个跑不起来」
|
||
- 全局 `ValueError` → 400 处理器:手写 query string 不再能把 500 页面暴露出去
|
||
- 日期归一化 `norm_day()` / `norm_window()`(容忍 `2026/09/08`、带时间、起止写反)
|
||
- CSV 导出改用 `csv.writer` 流式写入(此前手工拼字符串,`model`/`client` 含逗号会串列)
|
||
- `bundle` 明细加下限(`BUNDLE_RECORDS_CAP = 20000`),并返回
|
||
`recordsTotal` / `recordsCap` / `recordsTruncated`,不静默丢数据
|
||
- 轮询日志尾部改用 `collections.deque(maxlen=n)`,不再把整个文件读进内存
|
||
- 修掉一条非法 CSS 声明 `font: 13px/1.5 inherit`(简写里 `inherit` 不能当字族,
|
||
整条被浏览器丢弃,输入框一直用默认字体)
|
||
|
||
### 工具
|
||
|
||
- 新增 `tools/smoke.py`:**离线回归 99 项断言**(全页面只读渲染 + 模板残留 +
|
||
历史缺陷防回归 ①~⑭ + 静态资源逐个 200 + CSV 列 + 页面 class ↔ `app.css` 选择器对账),
|
||
不需要先起服务
|
||
- `tools/check_live.py` 扩充到 **56 项断言**,新增用户管理 / 审计 / 流式导出 /
|
||
开放重定向 / CSRF 四节
|
||
- 新增 `tools/shots.py`:Playwright 登录后逐页截图 + 收集 console / pageerror
|
||
(内建可执行文件探测,规避驱动与本机浏览器版本错位)
|
||
|
||
---
|
||
|
||
## [1.0.0] — 2026-09-14
|
||
|
||
**主题:从「脚本 + CSV + 静态大屏」演化为独立可部署的门户**
|
||
|
||
### 新增
|
||
|
||
- 独立 Flask 应用:`create_app` 工厂 + `views` / `api` 双蓝图
|
||
- SQLite(WAL)作为**唯一数据正本**,主键去重、断点续采、聚合全部下推 SQL
|
||
- 进程内调度线程(20 秒轮询 + 槽位去重 + 启动补跑),**不再依赖外部计划任务**
|
||
- 单写者文件锁 `data/collect.lock`(含 30 分钟僵尸锁抢占)
|
||
- 登录鉴权(`pbkdf2:sha256:200000`)、全站 CSRF、同 IP 失败限速
|
||
- 后台页面:概览 / 数据明细 / 任务管理 / 配置管理 / 日志管理
|
||
- 独立 ECharts 交互大屏 `/dashboard`(离线自带 echarts,不依赖 CDN)
|
||
- CLI:`init` `serve` `collect` `migrate-csv` `import-xlsx` `import-creds`
|
||
`fill-prompt` `export-csv` `stats` `status` `passwd` `vacuum`
|
||
- 从旧版 CSV / 官网 xlsx / 编辑器设置导入的迁移通道
|
||
- 从 VSCode / Cursor / Trae 的 `settings.json` 接管 Cookie 与 User-Agent
|
||
|
||
### 变更
|
||
|
||
- 数据正本从 CSV 改为 SQLite;CSV 降级为导出物(`data/exports/`)
|
||
- 采集从外部脚本改入 Web 进程;删除全部外部自动化与计划任务
|
||
- 运行期配置(Cookie、调度、采集参数)从文件搬进数据库,由后台页面维护
|
||
|
||
---
|
||
|
||
## 版本对照
|
||
|
||
| 版本 | 数据正本 | 调度 | 部署 | 鉴权 |
|
||
|---|---|---|---|---|
|
||
| 1.1.0 | SQLite(WAL) | 进程内 | Docker Compose / 裸机 | 登录 + CSRF + 角色 |
|
||
| 1.0.0 | SQLite(WAL) | 进程内 | 裸机 | 登录 + CSRF |
|
||
| < 1.0 | CSV 文件 | 外部计划任务 | 脚本 | 无(仅局域网) |
|