文件
wangchuanli f36149efc3 feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味):
- 大屏页清除 114 处生成器残留属性 data-page-node-id
- 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、
  去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点
- 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号
- 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令)

安全与隐私(按「将会被公网访问」收口):
- 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal)
- 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验
- 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异
- /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle)
- 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限
- 表名与库文件路径只对管理员下发;大屏页所有数据插值转义
- --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器

文档:
- DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行
- SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项
- .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库)

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
2026-09-18 11:13:17 +08:00

39 KiB

WorkBuddy Portal

workbuddy-portal —— WorkBuddy 积分用量「采集 / 存储 / 呈现」一体化门户

一个独立部署的 Python / Flask 应用:把账号云端的用量明细按时采集下来、按 request_id 去重存档,再以「管理后台(配置 / 任务 / 日志 / 明细)+ ECharts 交互大屏」两种形态呈现。 不依赖任何外部计划任务或自动化——调度线程就跑在 Web 进程里。

语言 / 框架 Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 urllib 采集
存储 SQLite(WAL),单文件正本 data/usage.sqlite
前端 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 echarts.min.js)
部署 三条路径:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表
鉴权 多用户(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通)
权限 普通账号只能维护本人的 Cookie / User-Agent;调度频率、采集参数、日志、用户管理、备份都归管理员
凭证 Cookie ChaCha20 + HMAC 静态加密入库,页面与接口只回掩码
防攻击 登录 / 注册图形验证码(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单
备份 自动 + 手动一致性快照、按份数清理、在线下载、一键恢复(恢复前自动再存一份);用户可导出本人全部数据
资源上限 容器层 cpus / mem_limit / pids_limit;实例层采集最小间隔、采集跨度硬顶 31 天、有任务在跑不允许新建任务
版本 v1.5.0
许可证 MIT(第三方组件与再分发资源见 THIRD-PARTY-NOTICES.md)

目录:核心特性 · 架构 · 快速开始 · 命令一览 · 页面一览 · 接口一览 · 目录结构 · 文档导航 · 安全须知 · 开源与许可


核心特性

能力 说明
多用户隔离 每个账号只填自己的 Cookie、收自己的数据、看自己的记录。user_id 是所有查询的第一个条件,且是必填位置参数(漏传直接 TypeError,不会静默返回全量)
两级权限 注册出来的账号一律是普通账号,能改的只有本人凭证(cookie / user_agent)。写权限只有一条规则:config.writable_by(key, is_admin) —— 页面与接口共用同一个函数,避免「界面置灰但接口还能写」这类规则漂移
配置作用域对齐 「键存在哪一级」与「谁能改」是一件事:实例级键(调度、采集参数、注册策略…)一律存 user_id=0 且仅管理员可写。不会出现「管理员改了只有自己生效」(那样别人读时会回落到默认值,是个静默 bug)
凭证加密 Cookie 以 ChaCha20(RFC 8439)+ HMAC-SHA256 encrypt-then-MAC 密文入库;主密钥单独放在 data/instance.json,与 SECRET_KEY 分开。升级时会把历史明文自动加密
自助注册 + 验证码 开放注册(可关),登录/注册均带图形验证码。答案是服务端本地点阵渲染的 PNG,只存库、一次性、5 分钟过期——不进会话(Flask 会话是签名不加密的,放进去等于送答案)
增量采集 按 MAX(ts) 断点续采 + 回退窗口;主键 ON CONFLICT 去重,冲突时以「更早的本地时间」为准
进程内调度 每天固定时刻(默认 09:00,17:00)由内置线程逐个启用账号触发;支持启动补跑(程序没开时错过的时刻,开机后在宽限期内补上)
单写者保证 文件锁 data/collect.lock 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占
全量存档 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 Prompt,可用 fill-prompt 回补
大屏去中间层 大屏直接走 /api,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求
可观测 每次采集落一条 collect_runs(含 [warn]/[error] 逐行原文);另有操作审计与登录审计,均带账号归属;访问日志写进 logs/app.log(waitress 本身不记 access log)
备份与恢复 备份走 SQLite 在线备份 API(不是 cp,采集正在写也能拿到一致快照),归档成含所有 *.sqlite + instance.json 的 zip;可设周期与保留份数、管理员下载与一键恢复(恢复前自动再存一份当前库)、普通用户导出本人全部数据。归档不在 data/ 卷里,down -v 不会连备份一起删
可安全对外 容器层限 cpus/mem_limit/memswap/pids_limit/nofile;实例层限采集最小间隔、每日时刻数、采集跨度硬顶 31 天;有任务在跑时新建任务直接 409;默认不信任 X-Forwarded-For(否则三道 IP 防线全废)

架构一图

                    ┌──────────────── workbuddy-portal(单进程)────────────────┐
  云端用量接口       │                                                          │
  /billing/meter/   │  scheduler.py ──┐  按实例级时刻表遍历启用账号               │
  get-user-request- │  (20s 轮询槽位)  │                                        │
  usage             │                 ▼                                        │
        ▲           │  collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite  │
        │           │      ▲        (全部带 user_id)      data/usage.sqlite(WAL)│
        └───────────┼──────┘                                     ▲               │
   client.py(urllib)│  ▲ crypto.py 解密本账号 Cookie            │               │
                    │  └ captcha.py 出验证码图                  │               │
                    │                        query.py(uid 为第一个查询条件)    │
                    │                              ▲            ▲              │
                    │            web/views.py ──────┘            └──── web/api.py│
                    │            (Jinja 后台)                       (JSON)    │
                    └───────────────┬───────────────────────────┬──────────────┘
                                    ▼                           ▼
                    / /records /tasks /config                /dashboard(ECharts 大屏)
                 /profile  [/logs /users:仅管理员]  +  未登录:/login /register

五层职责:

层 位置 说明
采集 workbuddy_portal/collect.py + scheduler.py 纯 urllib 调云端;断点、去重、锁、导入导出,全部按 uid 隔离
存储 workbuddy_portal/db.py + schema.sql SQLite WAL,单写者;运行期配置也在库里(settings 表,主键 (user_id, key))
加固 workbuddy_portal/crypto.py + captcha.py 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用自写 PNG 编码器出图
备份 workbuddy_portal/backup.py 在线备份 API 出快照 → zip 归档 → 按份数清理 → 校验 → 整表恢复(含恢复前自动兜底)
聚合 workbuddy_portal/query.py daily / dims / top / records / summary / bundle,全部下推 SQL,uid 是第一个条件
呈现 workbuddy_portal/web/ Jinja 后台(views.py)+ JSON API(api.py)+ 静态大屏

为什么验证码不用 SVG、也不用第三方库:SVG 是文本,答案会明文出现在页面源码里; 而本项目坚持 requirements.txt 只有 Flask / waitress / openpyxl,所以 PNG 编码器 (zlib 压缩 IDAT)与 5×7 点阵字模都是手写的,见 架构说明。


快速开始

三条路径产出的是同一份代码、同一个数据库格式,可以互相迁移。完整参数与排错见 部署与运维指南。

路径 A:裸机 Python

pip install -r requirements.txt

python manage.py init --user admin --password '一个足够强的密码'
python manage.py import-creds         # 可选:把编辑器设置里的 cookie/UA 接管进数据库
python manage.py migrate-csv          # 可选:把旧版 CSV 存档全量导入
python manage.py serve                # 启动,默认 0.0.0.0:8848

路径 B:Docker 自打包

git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal

cp .env.example .env            # 至少设好 WB_ADMIN_PASSWORD
docker compose up -d --build
docker compose logs -f          # Ctrl-C 退出日志跟踪,容器继续跑

路径 C:compose 拉云端镜像(不需要 clone 仓库)

在任意空目录写一个 docker-compose.yml(内容见 部署指南第五节)+ .env,然后:

docker login git.iwali.top -u <用户名>   # 密码填 Access Token(公开仓库可跳过)
docker compose pull
docker compose up -d

三条路径跑起来后都是:打开 http://<IP>:8848 → 用管理员登录 → 先把密码改掉 → 去「配置管理」粘贴 Cookie。

数据、日志与备份放在 Docker 命名卷(workbuddy-portal_wb_data / _wb_logs / _wb_backups)里,docker compose down 不会删。要用 CLI 就 docker compose exec portal python manage.py …。 备份刻意独立成一个卷:与正本同卷时,一次 down -v 会把两者一起带走 —— 那正好是最需要备份的时刻。 不要在宿主机上跑 manage.py 去连容器的库——Windows + Docker Desktop 的 9p 挂载下, 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。 想直接看到数据/日志,用 docker-compose.hostdir.yml 叠加层(仅建议 Linux 宿主机)。 详见 部署与运维指南。

从旧版本升级不需要手工介入:init 由 PRAGMA user_version 驱动,自动迁移且幂等。 v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密); v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级); v1.3.0 → v1.4.0 只加一列(users.session_ver)与一张新表(backups), 无数据搬运;v1.4.0 → v1.5.0 没有库结构变更(纯界面与安全加固)。 四次都带审计留痕,可重复执行。见 CHANGELOG。

第一次使用必做

管理员:

  1. 拿到并改掉初始密码——WB_ADMIN_PASSWORD 留空时程序会生成随机口令, 只在启动日志里打印一次(docker compose logs portal | grep -A6 管理员初始口令)。 没有 admin123 这类默认口令了,拿不到就去查看日志。
  2. 填自己的 Cookie——「配置管理 → 凭证」,否则采集只会记一条 no_cookie。
  3. 确认调度与限制——「任务管理」里把 09:00,17:00 改成你的习惯时刻; 同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是实例级的, 对全站账号生效)。
  4. 确认自动备份——「备份管理」页看状态与保留份数;建议点一次「立即备份」, 并把一份归档下载到容器之外(备份留在同一台机器上只防「改错了」,不防「机器没了」)。

普通账号(注册进来的默认身份):

  1. 改密码——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。
  2. 填自己的 Cookie——这是你唯一需要动手的配置。
  3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
  4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。

想给同事开账号?

登录页底部有「自助注册」入口(管理员可在「配置管理 → 实例级设置」关掉)。 注册同样要过验证码,且同一来源每天最多注册 3 个账号(可改)。 注册出来的都是普通账号:只能维护自己的 Cookie、只看自己的数据,看不到日志与用户管理。 每个账号登录后填自己的 Cookie——系统不会、也无法把某人的凭证给别人用。

只想内部开号、不开放注册?管理员在「用户管理」页直接新建即可; 命令行也行:python manage.py passwd alice 强密码(默认普通账号,加 --role admin 提权)。


命令一览

统一入口是 manage.py(Docker 里同样可用:docker compose exec portal python manage.py stats)。

所有涉及数据/凭证的子命令都作用于某一个账号,用 -u/--user <用户名> 指定; 不指定则取「管理员优先、其次 id 最小」的那个(所以旧习惯的单账号用法仍然成立)。 唯独 collect 不带 -u 时会逐个启用账号跑一遍,与进程内调度线程的行为一致。

命令 作用
init 初始化 / 迁移数据库(幂等)。--user / --password 指定首个管理员
serve 启动 Web。--host --port --debug --no-scheduler
collect [-u 账号] 执行一次增量采集后退出;不带 -u 则所有启用账号各跑一次
migrate-csv [文件] [-u 账号] 从旧版 CSV 存档导入(默认自动探测 legacy-v1/data/usage_records.csv 等路径),必须说明「算谁的」
import-xlsx <文件> [-u 账号] 合入官网「用量明细-导出」的 xlsx
import-creds [-u 账号] 从 VSCode / Cursor / Trae 的 settings.json 读取 codebuddyUsage.* 写入该账号
fill-prompt [-u 账号] 回补缺失的 User Prompt(官网导出会丢约 22%)
export-csv [路径] [-u 账号] 导出 CSV(默认 data/exports/usage_records_<账号>.csv,文件名带归属)
vacuum wal_checkpoint(TRUNCATE) + VACUUM,回收空闲页、压缩 WAL
backup [--note 说明] 立即打一份备份(所有 data/*.sqlite + instance.json → 一个 zip),并按保留份数清理最旧的
backups [--prune] [--keep N] 列出备份(大小 / 条数 / 积分 / 来源 / 时间),--prune 顺便按保留份数清理
restore <文件名> --yes 从备份恢复(整表替换)。破坏性操作,必须显式 --yes;不加只打印将要发生什么。恢复前会自动把当前库另存一份
stats [-u 账号] 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细
users 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP
status 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集
passwd <用户> [新密码] 重置或创建账号;--role admin 提权,--activate 顺手启用

自检工具

脚本 层 说明
tools/smoke.py 离线回归 test_client 对真实库全页面只读渲染,215 项断言:历史缺陷防回归、多用户隔离 / 凭证保密 / 注册与验证码全链路、非管理员越权面全关死、全局键必须落在实例级、CSV 列、class↔CSS 对账、静态资源逐个 200。不需要先起服务
tools/check_live.py 真实 HTTP 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项 → 验证码与响应头),122 项断言,基本只读。--as 账号:密码 追加普通账号越权验收
tools/shots.py 界面实检 Playwright 登录后逐页截图并收集 console / pageerror,含普通账号只读视角与越权面探测,产物在 data/shots/
tools/check_docs.py 文档自检 内部链接与跨文件锚点、图片引用、绝对路径泄漏、版本一致性(__init__ / Dockerfile / README / CHANGELOG 四处)、模板与 JS 里的产品名硬编码。文档互相引用后章节一重排,锚点会静默失效,这层把它变成可执行断言
tools/demo_data.py 示例数据 生成完全合成的示例库(管理员 + 普通账号各一份数据与假 Cookie),文档截图基于它
python tools/smoke.py                                          # 离线,随时可跑
python tools/check_docs.py                                     # 文档自检,有问题退出码 1
python manage.py serve --port 8849 --no-scheduler              # 另开一个终端
python tools/check_live.py --base http://127.0.0.1:8849 --as demo:admin123
python tools/shots.py    --base http://127.0.0.1:8849 --full   # 逐页截图(含普通账号视角)

check_live.py 的 --db 默认指向 data/usage.sqlite。对示例实例跑时必须显式传 --db data/demo/usage.sqlite,否则验证码答案从真实库取,会以「登录失败」的形式误导排查。

smoke.py 会对实例级配置做写入测试(管理员写 schedule_times 后还原), 并临时建两个普通账号用于验证权限边界与注册链路(无论成败都在 finally 里删掉), 不动任何用量数据;跑之前建议先备份,或在示例库上跑。 check_live.py / shots.py 只读,但登录成功会更新 users.last_login_at / login_count。 两者在验证码策略为 always 时会从本地库里取答案以完成自动登录 ——取的是会话里的 captcha id(答案本身只存在于服务端)。


页面一览

路径 作用 普通账号
/login 登录(未登录时的落点):用户名 / 密码 / 图形验证码,底部有自助注册入口 ✅
/register 自助注册:用户名、显示名、邮箱、密码 + 验证码 ✅(可被管理员关闭)
/ 概览:KPI(含今日 vs 昨日整日)、采集健康度、调度状态(只读)、模型 TOP、最近采集 ✅ 只看自己的
/dashboard ECharts 交互大屏(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 ✅
/records 数据明细:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV ✅
/tasks 任务管理:手动采集与按区间补采、运行历史。调度开关与时刻对普通账号是只读的 ⚠️ 只读调度
/config 配置管理:自己的 Cookie / UA(唯一可改)+ 只读的采集参数 + 维护动作;底部实例级设置区 ⚠️ 仅凭证可改
/profile 个人中心:账号概况、我的凭证状态(密文入库)、改密码(其他设备会话立即失效)、导出我的全部数据;点右上角用户名进入 ✅
/profile/export 导出本人全部数据(zip:使用记录 / 采集历史 / 操作审计 / 我的账号与配置;不含 Cookie 明文),10 秒限速 ✅
/logs 日志管理(仅管理员):全实例采集详情(含 [warn]/[error] 原文)、应用日志尾部、操作审计 ❌ 403
/users 用户管理(仅管理员):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 ❌ 403
/backups 备份管理(仅管理员):份数/占用/自动备份状态与下次时间、周期与保留份数设置、列表(下载 / 恢复 / 删除) ❌ 403

概览

其余页面截图见 用户手册;普通账号的只读视角见 docs/images/03b-tasks-user.png 与 04b-config-user.png。


接口一览

全部需要登录(/api/* 未登录返回 401 JSON);写接口另需 CSRF(请求头 X-CSRF-Token, 页面已注入 window.WB_CSRF)。 所有数据接口都只返回当前登录账号的数据 —— user_id 由会话决定,不接受客户端传入。 完整参数说明见 docs/API.md。

方法 路径 作用 权限
GET /api/manifest 存档总量、日期区间、存活日清单、数据源、健康状态(含 cookieChars/cookieBroken/role) 登录
GET /api/bundle 大屏一次取齐:全量 daily + 窗口 dims/top/records/totals 登录
GET /api/summary KPI + 环比(前一段不在存档内则不给假数字) 登录
GET /api/daily · /api/dims · /api/top 逐日聚合 / 模型·客户端·时段汇总 / 单笔消耗榜 登录
GET /api/records · /api/records/<id> 明细分页 / 单条详情(<id> 也受 user_id 约束) 登录
GET /api/runs · /api/runs/<id> 采集运行历史 / 单次详情(含逐行日志) 登录
GET /api/status 调度状态、下次执行、互斥锁、最近采集;含 is_admin / can_edit_schedule / can_view_logs 登录
GET /api/audit 操作审计分页 + 可选动作清单 管理员看全站,普通账号看自己
POST /api/collect 手动触发采集(可指定区间补采);未配 Cookie 回 409 no_cookie,密文解不开回 409 cookie_broken 登录(只采自己)
POST /api/maintenance/<action> fill-prompt | export-csv | vacuum | recount vacuum 仅管理员
GET/POST /api/settings 读 / 写配置。读只回掩码 cookie_hint,并回 _globalKeys / _userKeys / _role / _canEditGlobal;写非本人可写的键会整单拒绝(400 + denied 清单) 登录;写仅本人凭证或管理员
POST /api/profile · /api/password 改自己的显示名 / 邮箱 / 密码 登录
POST /api/captcha 验证码机制自述(策略、位数、TTL、图片地址),便于排障自检 登录
GET/POST /api/users · /api/users/<id> · /api/users/<id>/delete 用户管理 仅管理员
GET /captcha.png?purpose=login|register 图形验证码图片(唯一无需登录的接口;每次都是新题,带 no-store) 公开
GET /logs/tail 应用日志尾部 仅管理员
GET /records/export 按筛选流式导出 CSV 登录(只导自己)
GET/POST /api/backups 列出备份(含大小/条数/来源/是否存在) / 立即打一份 仅管理员
GET /api/backups/<filename> 下载一份归档(zip) 仅管理员
POST /api/backups/<filename>/restore 从该归档恢复(整表替换;include_instance 决定是否连密钥一起回滚) 仅管理员
POST /api/backups/<filename>/delete · /api/backups/prune 删除一份 / 按保留份数清理 仅管理员
GET /profile/export 导出本人全部数据(zip,不含 Cookie 明文) 登录

目录结构

workbuddy-portal/
├── manage.py                       统一 CLI(唯一入口)
├── requirements.txt                仅 Flask / waitress / openpyxl(零多余依赖)
├── Dockerfile                      多阶段构建(依赖层 / 运行层)
├── docker-compose.yml               单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限)
├── docker-compose.hostdir.yml       可选叠加层:改用宿主机目录(仅建议 Linux)
├── .env.example                     环境变量样例(含资源上限与反代开关)
├── docker/
│   ├── entrypoint.sh               幂等初始化 → exec serve(LF 行尾)
│   └── healthcheck.py              标准库健康检查(免登录页 /login)
├── docs/                           文档 + images/(手册配图,合成数据)
├── data/                           【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
├── logs/                           【运行时】app.log(滚动 2 MB × 3)
├── backups/                        归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略)
├── tools/
│   ├── smoke.py                    离线回归(215 项断言)
│   ├── check_live.py               真实 HTTP 验收(122 项断言,支持 --as 普通账号)
│   ├── shots.py                    Playwright 逐页截图 + JS 报错收集
│   ├── demo_data.py                生成合成示例库(管理员 + 普通账号)
│   └── push-all.sh                一条命令推代码 + 推镜像到 Gitea
└── workbuddy_portal/
    ├── __init__.py                 create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
    ├── config.py                   路径、项目标识、默认值、**配置作用域与写权限**、密钥管理
    ├── db.py                       SQLite 连接、schema、按作用域读写配置、账号、审计
    ├── schema.sql                  表结构(多用户布局)
    ├── crypto.py                   凭证静态加密(手写 ChaCha20 + HMAC-SHA256)
    ├── captcha.py                  图形验证码(手写 PNG 编码器 + 点阵字模)
    ├── security.py                 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
    ├── client.py                   云端接口(urllib)+ 编辑器凭证读取
    ├── collect.py                  增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
    ├── scheduler.py                进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份)
    ├── query.py                    SQL 聚合层(uid 必填)
    ├── backup.py                   备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复)
    └── web/
        ├── views.py                页面路由(含 /login /register /captcha.png /profile /backups)
        ├── api.py                  JSON API
        ├── templates/              base / login / register / profile / overview / tasks /
        │                           config / logs / records / users / backups / error
        └── static/
            ├── css/app.css         统一设计令牌
            ├── js/app.js           带 CSRF 的请求、表单与维护动作绑定
            ├── favicon.svg
            └── dashboard/index.html  ECharts 大屏(独立页)

backups/ 刻意不放在 data/:data/ 在 Docker 部署下是命名卷, docker compose down -v 会把备份和正本一起删掉——那正好是最需要备份的时刻。 容器里它挂的是独立的 wb_backups 卷;绝不用绑定挂载(Windows 9p 下会让容器 unable to open database file,且不自愈)。该目录同时被 .gitignore 与 .dockerignore 忽略: 归档里含 instance.json,进了镜像就等于把整库密钥分发给所有能拉镜像的人。

工作区根下另有一个 legacy-v1/(v1.0 单文件版归档,仅作 migrate-csv 的来源,可安全删除), 它在仓库之外,所以含真实数据的 CSV 不会被提交。


文档导航

文档 面向 内容
docs/USER-GUIDE.md 使用者 用户使用手册:注册、登录、配 Cookie、各页面操作、导出、权限与数据边界、信息安全与隐私安全、常见问题
docs/DEPLOYMENT.md 运维 部署与运维:三条部署路径(裸机 / Docker 自打包 / compose 拉云端镜像)、反向代理与 HTTPS、推镜像到 Gitea、备份恢复、升级回滚、巡检、排错、配置项速查
docs/ARCHITECTURE.md 开发 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍
docs/API.md 开发 / 集成 接口参考:路径、参数、返回结构、错误码
docs/FAQ.md 所有人 常见问题:采集为空、Cookie 失效、时区、性能、权限
docs/CHANGELOG.md 所有人 变更日志
CONTRIBUTING.md 贡献者 贡献指南:开发环境、验证分层、必须遵守的不变量、提交规范
SECURITY.md 运维 / 安全 安全策略:漏洞私有报告渠道、已有措施、已知非目标
THIRD-PARTY-NOTICES.md 合规 第三方组件清单与许可证(含随仓库再分发的 ECharts)
CODE_OF_CONDUCT.md 所有人 行为准则
LICENSE 所有人 MIT 许可证全文

安全须知

局域网甚至公网可访问 ⇒ 以下每一条都必要:

  • 初始口令不是固定的(v1.4.0 起):WB_ADMIN_PASSWORD 留空时程序生成随机口令, 只在启动日志里打印一次、库里只存散列。没有 admin123 这类默认口令可猜, 代价是「拿不到那行日志就进不去」——首次启动后请立刻 docker compose logs portal | grep -A6 管理员初始口令。 给同事发普通账号(注册出来的默认身份),不要共用管理员 —— 管理员是能停用别人账号的角色。
  • 权限只有两档,且规则只有一条:config.writable_by(key, is_admin)。 普通账号能写的只有 USER_EDITABLE_KEYS = {cookie, user_agent}(且只限本人这份); 调度频率、采集参数、注册策略、日志、用户管理、备份全部关死。 页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 @admin_required 与 writable_by() 这两个服务端判断上——所以直接敲 URL 或构造请求也过不去。
  • 数据按账号隔离:uid 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量); /api/runs/<id>、/api/records/<id> 这类按 id 取的单条接口也带 user_id 约束; /logs、/logs/tail、/users、/api/users*、/backups、/api/backups* 仅管理员。
  • 不信任 X-Forwarded-For(默认):全站只有 security.client_ip() 一个取客户端地址的 入口。默认直接用 remote_addr;WB_TRUST_PROXY=1 时取最右侧合法 IP。 这一条不改就是三道 IP 防线同时失效:实测每次换一个伪造的 XFF,45 次验证码请求全部放行。
  • Cookie 静态加密:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在 data/instance.json 的 cookie_key(与 SECRET_KEY 分开,轮换代价不同), POSIX 上该文件权限收到 0600。get_settings() 把加密键一律置空,要明文只有 db.get_secret() 一条路——这样任何「顺手打印全部配置」的代码都带不出凭证。
  • Cookie 不跨账号回落:NO_FALLBACK_KEYS(cookie / user_agent)不参与实例级回落, 否则新账号会「继承」管理员的凭证,属于最严重的串号越权。
  • 实例级也不存凭证:init_db() 灌默认值时会跳过 USER_EDITABLE_KEYS 并显式删除 实例级的 cookie / user_agent 行——实例级存凭证等于给所有账号发同一张身份。
  • 验证码先于口令校验:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号, 在解验证码之前就把字典跑完。答案存服务端 captchas 表,一次性、5 分钟过期、按用途隔离, 下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。 字模本身带逐字符随机旋转 / 切变 / 缩放抖动 / 波浪偏移,同一验证码两次渲染字节差异 76.9%。
  • 注册受双重限制:验证码 + 同 IP 每日配额(默认 3 个,可改;allow_register=0 可整体关闭)。
  • 停用账号立即失效:current_user() 每个请求回查 users.status 与 users.session_ver, 不必等 12 小时会话过期。改密码 / 管理员重置口令 / 停用 / 删除都会让该账号的所有其他设备上的 会话立刻作废(改自己密码时保留当前这次会话,否则改完立刻被自己踢下线); 恢复备份会让全站所有会话作废。
  • 口令有黑名单:WEAK_PASSWORDS(34 个自动撞库字典的头几页)在设置/修改口令时拒绝, 登录时不校验 —— 否则会把用老口令的存量用户挡在门外。
  • CSRF 全站校验,退出登录也是 POST(GET 型退出能被 <img src="/logout"> 静默触发)。 前端 formData() 会跳过 disabled 控件(含祖先 fieldset[disabled])—— disabled 的 input 仍在 form.elements 里,一起提交会让服务端因「越权修改只读项」拒掉整单。
  • 开放重定向防护:登录跳转的 next 只接受站内相对路径,//evil.com 这类协议相对 URL 一律回落到 /。
  • 登录限速按两个维度、且强度刻意不同:IP 维度连续失败 5 次真锁 10 分钟; 用户名维度只做秒级递增退避(封顶 60 秒)——因为「知道用户名就能把对方锁死 10 分钟」 本身就是攻击,而管理员用户名在导航栏里是公开的。另有单来源登录尝试总量 (40 次 / 5 分钟,含成功)挡住「慢慢撞、不触发失败阈值」的形态。 失败提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时。 验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
  • 重操作都有最小间隔:手动采集 60 秒、CSV 导出 15 秒、导出本人数据 10 秒、 vacuum 120 秒、补全 prompt 60 秒。采集跨度硬顶 31 天(代码层 COLLECT_MAX_RANGE_DAYS_HARD, 改配置也突破不了);有采集任务在跑时新建任务直接 409。
  • 安全响应头:CSP(frame-ancestors 'none')、X-Frame-Options: DENY、nosniff、 Referrer-Policy: same-origin、COOP;/api/* 与 /captcha* 带 Cache-Control: no-store。 HSTS 只在真的走 HTTPS 时下发(COOKIE_SECURE or FORCE_HTTPS)——纯 HTTP 部署下发会让 浏览器强升 https,表现成白屏,是个很难归因的故障。
  • 有访问日志:waitress 自己不记 access log,本程序补上(写进 logs/app.log, 跳过 /static/ 与验证码图片)。出事无据可查时这一条很值。
  • 不进版本库、也绝不进镜像的文件:data/instance.json(含 secret_key 与 cookie_key)、 data/usage.sqlite、data/shots/、data/demo/、backups/(归档里含 instance.json)、 logs/、.env(含明文密码)。.dockerignore 里 backups/ 这条是硬要求, Dockerfile 还有一条构建期断言兜底:万一漏了就直接构建失败 —— 镜像层是只读叠加的,靠 RUN rm 补救只会多留一个含内容的中间层。

开源与许可

本项目以 MIT 许可证发布,全文见 LICENSE。你可以自由使用、修改、商用与再分发, 只需保留版权声明与许可声明。

第三方组件(务必看一眼)

用量大屏随仓库再分发了 Apache ECharts 5.6.0(workbuddy_portal/web/static/dashboard/vendor/echarts.min.js, Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在局域网内离线可用。 按 Apache-2.0 第 4 条,再分发时需保留其许可证与版权声明(该文件头部已自带)。 其余运行期依赖(Flask / waitress / openpyxl)不在本仓库内,由使用方安装时获取。

完整的依赖清单、许可证对照表与合规自查清单见 THIRD-PARTY-NOTICES.md。

参与贡献

  • 想改代码?先读 CONTRIBUTING.md —— 里面有必须遵守的几条不变量 (SQLite 单写者、列表接口不回 prompt 全文、两种字段命名契约不要互相「统一」、 写权限只能走 config.writable_by……),以及从 compileall 到容器验证的五层自检该怎么跑。
  • 有想法但手上没有真实数据?python tools/demo_data.py 会生成一份完全合成的示例库, 写到 data/demo/(已在 .gitignore 内),可直接拿来调试界面与截图。
  • 发现安全漏洞?请不要开公开 Issue,按 SECURITY.md 走私有渠道。
  • 参与本项目即表示你同意遵守 CODE_OF_CONDUCT.md。

文档里的数据都是合成的

docs/images/ 的全部界面截图与 docs/ 中的 JSON 示例均为合成数据: 模型名统一为 demo-*,客户端为 vscode / webconsole / sdk,Prompt 为通用示例文本, Cookie 是 deadbeef… / cafef00d… 这类一眼可辨的假串, 审计 IP 取自 RFC 5737 的文档专用网段(192.0.2.0/24)。 生成方式是 tools/demo_data.py(会造 admin 与 demo 两个账号,各有自己的数据), 所以任何人不需要真实账号就能复现整套文档。截图由 tools/shots.py 逐页重出(13 张, 含注册页、个人中心,以及普通账号视角的只读「任务管理 / 配置管理」), 脚本会读示例库里的验证码答案自动过掉登录。

⚠️ 重出截图时务必用相对路径起示例服务:

cd workbuddy-portal
WB_DATA_DIR=data/demo WB_LOG_DIR=data/demo/logs python manage.py serve --port 8849 --no-scheduler

用绝对路径(包括 Git Bash 里的 $PWD,它展开成 /c/...)会让启动日志印出 C:\Users\<用户名>\…,而那一行正好会出现在「日志管理」页的截图上。 另外 Git Bash 下 $PWD 是 MSYS 风格路径,Python 会把它解析成 C:\c\Users\… —— 于是服务连的是另一个空库,界面上看不出异常,但截图数据全不对 (症状:验证码取不到答案,shots.py 报「需要验证码但取不到答案」)。

致谢