文件
wangchuanli 4720149c20 fix(发布): push-all.sh 补上 git tag,并补打 v1.2.0 ~ v1.4.0 历史标签
问题:脚本里只有 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
2026-09-18 14:54:48 +08:00

44 KiB

变更日志

本项目遵循 语义化版本:主版本.次版本.修订号。

  • 主版本:不兼容的变更(数据库迁移需手工介入、接口契约变化)
  • 次版本:向后兼容的功能新增
  • 修订号:向后兼容的缺陷修复

[未发布]

发布流程修复:脚本从来没打过 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 <」)。
  • 文档:新增 部署指南第十三节「安全与隐私基线」 —— 代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。

验证: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/: 用户使用手册(含 9 张界面截图,全部用合成示例数据渲染)、 部署与运维指南(含推镜像到 Gitea 注册表的完整流程)、 架构与设计说明、 接口参考、 常见问题
  • 开源声明体系(仓库根目录):LICENSE(MIT)、 THIRD-PARTY-NOTICES.md(依赖清单与再分发合规自查)、 CONTRIBUTING.md(含「必须遵守的不变量」与五层自检方法)、 SECURITY.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 第十一节。

安全

  • 新增 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 文件 外部计划任务 脚本 无(仅局域网)