2 次代码提交
作者 SHA1 备注 提交日期
wangchuanli 3751dffef9 feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
2026-09-18 08:46:34 +08:00
wangchuanli 1bf961f6b3 feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
2026-09-18 08:46:00 +08:00
共修改 57 个文件,包含 5662 行新增和 992 行删除
+12
查看文件
@@ -8,6 +8,18 @@
data/
logs/
# ---- 备份归档:必须排除,这条是硬要求 ----
# backups/ 里的 zip 含 data/instance.json,而那个文件躺着 SECRET_KEY 与
# cookie_key。漏了这一行,`docker build` 会把整库密钥原样烤进镜像层 ——
# 推一次镜像就等于把密钥分发给所有能拉镜像的人,而 Dockerfile 里后续的
# chown/chmod 都改不掉「镜像层里已经有一份」这个事实(层是只读叠加的,
# 想靠 COPY 之后再 RUN rm 抹掉也只会多留一个含内容的中间层)。
# 换言之:这不是「少拷一个目录」,是**凭证泄露**,只能在这里挡。
backups/
# 示例数据是 tools/demo_data.py 的产物,运行时不需要(镜像再小一点)
data/demo/
# Python 缓存
# 注意:`__pycache__/` 只能匹配上下文**根目录**下的同名目录,
# 嵌套的(如 tools/__pycache__)必须用 `**/` 前缀——否则会被原样打进镜像。
+44 -3
查看文件
@@ -10,10 +10,50 @@ WB_PORT=8848
TZ=Asia/Shanghai
# ---------- 首个管理员(只在数据库为空时生效)----------
# 强烈建议:首次启动前就设好,避免用默认的 admin/admin123 暴露在局域网上
# 强烈建议:首次启动前就设好。
# 留空时程序会生成**随机**口令,并且只在启动日志里打印一次:
# docker compose logs portal | grep -A1 口令
# 抄下来登录后立刻改掉。(1.4.0 起不再有 admin123 这类硬编码兜底。)
WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD=
# ---------- 容器资源上限 ----------
# 应用层已经有「采集频率 / 并发 / 采集跨度」三重业务刹车,这里是**进程**层面的
# 兜底:一次异常(内存泄漏、超大导出、正则回溯)不能把整台机器带下去。
# 单写者架构下不要靠加副本扛负载,限制单实例资源才是正解。
#
# CPUS : CPU 上限,允许小数。SQLite 是单写者,1.0 已经够用;
# 压测后再调大,别一上来就给满宿主机的核。
# MEM_LIMIT : 内存上限。500 条/页采集时的常驻内存在 100~200 MB 量级,
# 512m 留了充裕余量;内存与 swap 同时设成这个值 = 禁用 swap,
# 这样超限会「被 OOM 杀掉」而不是「越来越慢」——后者更难查。
# PIDS_LIMIT : 进程/线程数上限,挡 fork 炸弹。tini + python + waitress
# 线程模型下 256 很宽松。
WB_CPUS=1.0
WB_MEM_LIMIT=512m
WB_PIDS_LIMIT=256
# ---------- waitress 线程数 ----------
# 这是「单实例能同时吃进几个慢请求」的上限(采集 / 导出 / 备份恢复都算慢请求)。
# 与 CPU 上限配套:1 核配 8 线程容易出现「都在等 CPU」的假并发,默认 4。
WB_THREADS=4
# ---------- 反向代理与传输安全(三个必须一起决定)----------
# WB_TRUST_PROXY:是否信任 X-Forwarded-For。**默认 0 = 不信任**。
# 直接暴露给公网时必须留 0。置 1 的后果很严重:攻击者每次换一个伪造的
# XFF,验证码限速、注册配额、登录锁定这三道 IP 防线会**同时失效**。
# 置 1 的前提只有一个:**你自己的**反向代理会写这个头。
# 开启后程序取 XFF 里**最右侧**的合法 IP,而最右侧是离你最近的那一跳
# (由你的代理写入),客户端伪造不了。因此 nginx 侧两种写法都安全:
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 保留链路,便于排查
# proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,最保守
# 真正不能做的是:去信 XFF 里**最左边**那一段(那是客户端自己填的)。
WB_TRUST_PROXY=0
# 前面挂了 HTTPS 反代时置 1(读到 X-Forwarded-Proto: https 就不跳转)
WB_FORCE_HTTPS=0
# 是否记录访问日志(写进 logs/app.log,静态资源与验证码图片除外)
WB_ACCESS_LOG=1
# ---------- 调度 ----------
# 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
WB_DISABLE_SCHEDULER=0
@@ -34,11 +74,12 @@ WB_IMPORT_XLSX=
# ---------- 镜像名(推送 Gitea 注册表时用)----------
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.2.0
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
# ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ----------
# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
# 把数据/日志/备份放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
# Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后,
# 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。
# WB_HOST_DATA_DIR=./data
# WB_HOST_LOG_DIR=./logs
# WB_HOST_BACKUP_DIR=./backups
+16
查看文件
@@ -20,6 +20,11 @@ data/**/instance.json
data/exports/*.csv
# 兜底:手工 cp 出来的快照(如 usage.sqlite.bak-pre-v13)绝不能入库,
# 它含 settings 的凭证密文与 users 的密码哈希
data/*.bak*
data/*.sqlite-bak*
# 界面截图(tools/shots.py 生成的临时产物;手册配图在 docs/images/)
data/shots/
@@ -29,6 +34,17 @@ data/demo/
# ---- 日志 ----
logs/*
# ---- 数据库备份 ----
# 全目录忽略,只放行说明文件。备份含凭证密文与密码哈希,永不入库。
# 刻意不放在 data/:data/ 是 Docker 卷,down -v 会把备份和正本一起删掉。
backups/*
!backups/README.md
!backups/.gitkeep
# ---- v1.0 旧版归档 ----
# 若仓库被整体移到工作区根,legacy-v1/(含真实 CSV)必须继续被忽略
legacy-v1/
# ---- 保留目录本身 ----
# docker-compose 是绑定挂载,宿主机目录必须先存在,
# 否则 Docker 会以 root 自动创建,Linux 上会引发「unable to open database file」
+26 -9
查看文件
@@ -44,14 +44,16 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
## 三、改动前请先跑一遍验证
项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层:
项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层;
**只要动过 `*.md`,第 1.5 层必须跑**:
| # | 命令 | 覆盖什么 | 需要什么 |
|---|---|---|---|
| 1 | `python -m compileall -q workbuddy_portal manage.py tools` | 语法 | — |
| 2 | `python tools/smoke.py` | **165 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848` | **83 项**真实 HTTP 断言,含登录/CSRF/开放重定向/验证码/安全响应头 | 一个运行中的服务 |
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror` | Playwright + Chromium |
| 1.5 | `python tools/check_docs.py` | **文档自检**:内部链接与跨文件锚点、图片引用、**绝对路径泄漏**、版本一致性、产品名硬编码。改过任何 md 都跑,否则章节重排造成的**锚点静默失效**会一路漏到线上。文档清单自动发现,不写死文件名 | — |
| 2 | `python tools/smoke.py` | **215 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、**非管理员越权面全关死**、**全局键必须落在实例级**、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848 --as demo:admin123` | **122 项**真实 HTTP 断言,含登录/CSRF/开放重定向/验证码/安全响应头,`--as` 追加普通账号越权验收 | 一个运行中的服务 |
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror`,**含普通账号只读视角**与越权面探测 | Playwright + Chromium |
| 5 | `docker compose up -d --build && docker compose ps` | 容器化路径 | Docker |
> `tools/shots.py` 是**最有价值的一层**:项目曾经出过「大屏整页全白」的 bug,
@@ -79,7 +81,7 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
6. **`.gitignore` 不支持行尾注释**——规则后跟 `# 注释` 会让整行失效。注释必须单独占一行,
改完用 `git check-ignore -v <file>` 逐条确认命中。
### 多用户相关的四条(v1.2.0 起)
### 多用户与权限相关的六条(v1.2.0 起,v1.3.0 扩充)
7. **`uid` 必须是 `conn` 之后的第一个位置参数,且不给默认值。**
这是防越权的核心机制:漏传就直接 `TypeError`,而不是静默返回所有人的数据。
@@ -90,24 +92,39 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
9. **验证码答案只能放服务端。** 不要图省事塞进 `session`——Flask 的 session 是
「签名 + base64」而非加密,客户端能直接解开读到答案。下发给浏览器的只有随机 `captcha_id`;
且校验时**先删后判**(一次性)。同理,验证码图不要用 SVG 渲染,那玩意是文本。
10. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。**
10. **写权限只有一个入口:`config.writable_by(key, is_admin)`。**(v1.3.0)
页面上的置灰 / 隐藏只是「不给误导性按钮」,真正的闸门是服务端判断 ——
所以别在模板里另写一套「哪些键只读」的条件,那必然会和接口判断漂移。
新增一个可配置项时,先决定它的归属:`GLOBAL_KEYS`(实例级、仅管理员)
还是 `USER_EDITABLE_KEYS`(个人级、人人可写本人那份),然后只改这一处。
11. **「键存哪一级」和「谁能写」必须对齐。**(v1.3.0)
反例:把采集参数只写进管理员自己的 `user_id`,其它账号读取时会回落到 `DEFAULTS`,
于是**管理员改的值对别人完全不生效** —— 不报错、不进日志,是个纯粹的静默 bug。
所以实例级策略(调度、采集参数、注册策略)一律存 `user_id=0`,
并由 `db.set_setting()` 强制重定向(`slot:*` 这类**个人簿记键**除外,它们本来就该是个人级)。
12. **`user_id=0` 不是孤儿行。**(v1.3.0)
实例级配置挂在一个不对应任何真实账号的 `user_id=0` 上。任何「清理孤儿行」的语句
都必须排除它 —— `DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`
会把整片实例级配置删掉(历史缺陷:实测把 19 个实例级键清到只剩 1 行)。
现在 `tools/smoke.py` 里有「跑完整轮 smoke 后实例级配置一条不少」的防回归断言,别删。
13. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。**
`ALTER TABLE … RENAME TO` 会**把索引一起带走**,后续 `CREATE INDEX IF NOT EXISTS`
就变成空操作,新表会零索引。本项目在迁移前先调 `_drop_all_user_indexes()`,
并把 `ALTER TABLE … ADD COLUMN` 放在 `executescript` 之前。
### 加密与验证码这两块(零依赖约束)
11. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」
14. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」
—— 所以 ChaCha20、HMAC、PNG 编码、点阵字模都是手写的。想引 `cryptography` 或 `Pillow`
之前先想清楚:这会让「下载即跑」的卖点消失。
12. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀
15. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀
原样返回(兼容历史明文),但**校验不过就抛 `DecryptError`**。静默降级会让加密形同虚设。
## 五、代码风格
- 遵循 PEP 8;行宽 100。
- **注释与文档字符串用中文**,说明「为什么这么做」而不是「这行在做什么」。
- 提交前用 `python -m compileall` 与 `python tools/smoke.py` 自查。
- 提交前用 `python -m compileall` 与 `python tools/smoke.py` 自查;**动过文档再加一条 `python tools/check_docs.py`**。
- 不要引入新的第三方依赖,除非有充分理由并在 PR 里说明——这个项目的卖点之一就是依赖少。
如果确实新增了,请同步登记到 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
+15 -4
查看文件
@@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
org.opencontainers.image.version="1.1.0" \
org.opencontainers.image.version="1.4.0" \
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
ENV PYTHONUNBUFFERED=1 \
@@ -36,6 +36,7 @@ ENV PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
WB_DATA_DIR=/app/data \
WB_LOG_DIR=/app/logs \
WB_BACKUP_DIR=/app/backups \
WB_HOST=0.0.0.0 \
WB_PORT=8848
@@ -55,16 +56,26 @@ WORKDIR /app
# --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志
COPY --chown=app:app . .
# 构建期断言:万一 .dockerignore 漏掉 backups/,在这里直接失败。
# 只靠「记得改 .dockerignore」不可靠 —— 备份归档里有 instance.json(含
# SECRET_KEY 与 cookie_key),进了镜像层就是整库密钥随镜像分发,且**无法**
# 用后续的 RUN rm 抹掉(只会多留一个含内容的只读层)。让构建自己拒绝。
RUN set -eux; \
if [ -n "$(ls -A /app/backups 2>/dev/null)" ]; then \
echo "ERROR: /app/backups 里有内容被打进镜像层 —— .dockerignore 漏了 backups/" >&2; \
exit 1; \
fi
RUN set -eux; \
chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \
mkdir -p /app/data /app/logs; \
chown -R app:app /app/data /app/logs; \
mkdir -p /app/data /app/logs /app/backups; \
chown -R app:app /app/data /app/logs /app/backups; \
python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)"
USER app
EXPOSE 8848
VOLUME ["/app/data", "/app/logs"]
VOLUME ["/app/data", "/app/logs", "/app/backups"]
HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \
CMD ["python", "/app/docker/healthcheck.py"]
+220 -107
查看文件
@@ -11,15 +11,18 @@
| 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 |
| 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` |
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
| 部署 | Docker Compose / 裸机 `waitress`;镜像可推 Gitea 容器注册表 |
| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 |
| 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) |
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理、备份都归管理员 |
| 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 |
| 版本 | v1.2.0 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 |
| 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 |
| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** |
| 版本 | v1.4.0 |
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
[页面一览](#页面一览) · [接口一览](#接口一览) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可)
[页面一览](#页面一览) · [接口一览](#接口一览) · [目录结构](#目录结构) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可)
---
@@ -27,16 +30,19 @@
| 能力 | 说明 |
|---|---|
| **多用户隔离** | 每个账号只填**自己的** Cookie、收**自己的**数据、看**自己的**日志。`user_id` 是所有查询的第一个条件,且是**必填位置参数**(漏传直接 `TypeError`,不会静默返回全量) |
| **多用户隔离** | 每个账号只填**自己的** 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`)由内置线程**按账号逐个**触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程逐个启用账号触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属 |
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 |
| **可观测** | 每次采集落一条 `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 防线全废) |
---
@@ -45,7 +51,7 @@
```
┌──────────────── workbuddy-portal(单进程)────────────────┐
云端用量接口 │ │
/billing/meter/ │ scheduler.py ──┐ 按账号逐个判断槽位 │
/billing/meter/ │ scheduler.py ──┐ 按实例级时刻表遍历启用账号 │
get-user-request- │ (20s 轮询槽位) │ │
usage │ ▼ │
▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │
@@ -59,8 +65,8 @@
│ (Jinja 后台) (JSON) │
└───────────────┬───────────────────────────┬──────────────┘
▼ ▼
/ /records /tasks /config /logs /dashboard(ECharts 大屏)
/users /profile + 未登录:/login /register
/ /records /tasks /config /dashboard(ECharts 大屏)
/profile [/logs /users:仅管理员] + 未登录:/login /register
```
五层职责:
@@ -70,6 +76,7 @@
| 采集 | `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`)+ 静态大屏 |
@@ -81,53 +88,86 @@
## 快速开始
### 方式一:Docker Compose(推荐)
三条路径产出的是**同一份代码、同一个数据库格式**,可以互相迁移。完整参数与排错见
[部署与运维指南](docs/DEPLOYMENT.md)。
### 路径 A:裸机 Python
```bash
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 自打包
```bash
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
cp .env.example .env # 至少设好 WB_ADMIN_PASSWORD
# 编辑 .env: WB_ADMIN_PASSWORD=一个足够强的密码
docker compose up -d --build
docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
```
打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。
### 路径 C:compose 拉云端镜像(不需要 clone 仓库)
> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里,
> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
在任意空目录写一个 `docker-compose.yml`(内容见
[部署指南第五节](docs/DEPLOYMENT.md#五路径-cdocker-compose-拉云端镜像部署))+ `.env`,然后:
```bash
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 宿主机**)。
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### 方式二:裸机 Python
> **从旧版本升级不需要手工介入**:`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`),
> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
```bash
pip install -r requirements.txt
### 第一次使用必做
python manage.py init # 建表 + 默认配置 + 管理员 admin/admin123
python manage.py import-creds # 可选:把编辑器设置里的 cookie/UA 接管进数据库
python manage.py migrate-csv # 可选:把旧版 CSV 存档全量导入
python manage.py serve # 启动,默认 0.0.0.0:8848
```
**管理员:**
> 从 v1.1.0 升级上来**不需要手工介入**:`init` 会检测到旧表结构并自动迁移
> (历史数据归到首个账号、明文 Cookie 就地加密),全程带审计留痕,可重复执行。
1. **拿到并改掉初始密码**——`WB_ADMIN_PASSWORD` 留空时程序会生成**随机**口令,
只在启动日志里打印一次(`docker compose logs portal | grep -A6 管理员初始口令`)。
**没有 `admin123` 这类默认口令了**,拿不到就去查看日志。
2. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
3. **确认调度与限制**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻;
同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是**实例级**的,
对全站账号生效)。
4. **确认自动备份**——「备份管理」页看状态与保留份数;建议点一次「立即备份」,
并**把一份归档下载到容器之外**(备份留在同一台机器上只防「改错了」,不防「机器没了」)。
### 第一次使用必做三件事
**普通账号(注册进来的默认身份):**
1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。
2. **填 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `cookie_expired`。
获取方式见 [用户手册](docs/USER-GUIDE.md#三获取并填写-cookie)。
3. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效。
1. **改密码**——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。
2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。
3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。
### 想给同事开账号?
登录页底部有「**自助注册**」入口(管理员可在「配置管理 → 实例级设置」关掉)。
注册同样要过验证码,且同一来源每天最多注册 3 个账号(可改)。
**注册出来的都是普通账号**:只能维护自己的 Cookie、只看自己的数据,看不到日志与用户管理。
每个账号登录后填**自己的** Cookie——系统不会、也无法把某人的凭证给别人用。
> 只想内部开号、不开放注册?管理员在「用户管理」页直接新建即可;
@@ -139,7 +179,7 @@ python manage.py serve # 启动,默认 0.0.0.0:8848
统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。
**多用户下所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定;
**所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定;
不指定则取「管理员优先、其次 id 最小」的那个(所以旧习惯的单账号用法仍然成立)。
唯独 `collect` 不带 `-u` 时会**逐个启用账号**跑一遍,与进程内调度线程的行为一致。
@@ -148,12 +188,15 @@ python manage.py serve # 启动,默认 0.0.0.0:8848
| `init` | 初始化 / 迁移数据库(幂等)。`--user` / `--password` 指定首个管理员 |
| `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` |
| `collect [-u 账号]` | 执行一次增量采集后退出;**不带 `-u` 则所有启用账号各跑一次** |
| `migrate-csv [文件] [-u 账号]` | 从旧版 CSV 存档导入(默认自动探测旧项目路径),必须说明「算谁的」 |
| `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 状态 / 最近采集 |
@@ -163,20 +206,26 @@ python manage.py serve # 启动,默认 0.0.0.0:8848
| 脚本 | 层 | 说明 |
|---|---|---|
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**165 项断言**:历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、CSV 列、class↔CSS 对账、静态资源逐个 200。**不需要先起服务** |
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项 → **验证码与响应头**),**83 项断言**,基本只读 |
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,产物在 `data/shots/` |
| `tools/demo_data.py` | 示例数据 | 生成**完全合成**的示例库(两个账号,各有自己的数据与假 Cookie),文档截图基于它 |
| `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),文档截图基于它 |
```bash
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 # 真实 HTTP
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
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 # 逐页截图(含普通账号视角)
```
> `smoke.py` 会写少量 `audit_log` 审计行,并**临时**建两个普通账号用于验证权限边界与注册链路
> (无论成败都在 `finally` 里删掉),不动任何用量数据;
> `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(答案本身只存在于服务端)。
@@ -185,22 +234,25 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
## 页面一览
| 路径 | 作用 |
|---|---|
| `/login` | **登录**(未登录时的落点):用户名 / 密码 / **图形验证码**,底部有自助注册入口 |
| `/register` | **自助注册**:用户名、显示名、邮箱、密码 + 验证码;注册成功直接登录并引导去填自己的 Cookie |
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态、模型 TOP、最近采集 |
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 |
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV |
| `/tasks` | **任务管理**:调度开关与时刻、启动补跑、按区间补采、运行历史 |
| `/config` | **配置管理**:自己的 Cookie / UA、采集参数、TLS 校验、维护动作;底部是实例级设置区(仅管理员可改) |
| `/logs` | **日志管理**:**只看得到自己账号的**逐次采集详情(含 `[warn]`/`[error]` 原文)、操作审计;应用日志尾部仅管理员 |
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 |
| `/users` | **用户管理**(仅管理员):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 |
| 路径 | 作用 | 普通账号 |
|---|---|---|
| `/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/01-overview.png)
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md)。
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md);普通账号的只读视角见
> `docs/images/03b-tasks-user.png` 与 `04b-config-user.png`。
---
@@ -211,27 +263,30 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
**所有数据接口都只返回当前登录账号的数据** —— `user_id` 由会话决定,不接受客户端传入。
完整参数说明见 [docs/API.md](docs/API.md)。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态(含 `cookieChars`/`cookieBroken`) |
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` |
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) |
| GET | `/api/daily` | 逐日聚合(含每日分模型、24 时段) |
| GET | `/api/dims` | 模型 / 客户端 / 时段汇总 |
| GET | `/api/top` | 单笔消耗榜(唯一带 Prompt 摘要的接口) |
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情(`<id>` 也受 `user_id` 约束) |
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) |
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集 |
| 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`;非管理员写实例级键会被拒(`400` + `denied` 清单) |
| POST | `/api/profile` | 改自己的显示名 / 邮箱 |
| POST | `/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` · `/records/export` | 应用日志尾部(仅管理员)/ 按筛选流式导出 CSV |
| 方法 | 路径 | 作用 | 权限 |
|---|---|---|---|
| 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 明文) | 登录 |
---
@@ -240,23 +295,27 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
```
workbuddy-portal/
├── manage.py 统一 CLI(唯一入口)
├── requirements.txt
├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖)
├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
├── docker-compose.yml 单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限)
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
├── .env.example 环境变量样例
├── .env.example 环境变量样例(含资源上限与反代开关)
├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
├── docs/ 文档(见下)
├── docs/ 文档 + images/(手册配图,合成数据)
├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
├── logs/ 【运行时】app.log(滚动 2 MB × 3)
├── backups/ 归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略)
├── tools/
│ ├── smoke.py 离线回归(165 项断言)
│ ├── check_live.py 真实 HTTP 验收(83 项断言)
│ ├── smoke.py 离线回归(215 项断言)
│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号)
│ ├── shots.py Playwright 逐页截图 + JS 报错收集
│ └── demo_data.py 生成合成示例库(两个账号)
│ ├── demo_data.py 生成合成示例库(管理员 + 普通账号)
│ └── push-all.sh 一条命令推代码 + 推镜像到 Gitea
└── workbuddy_portal/
├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
├── config.py 路径、项目标识、默认值、写时校验、密钥管理
├── config.py 路径、项目标识、默认值、**配置作用域与写权限**、密钥管理
├── db.py SQLite 连接、schema、按作用域读写配置、账号、审计
├── schema.sql 表结构(多用户布局)
├── crypto.py 凭证静态加密(手写 ChaCha20 + HMAC-SHA256)
@@ -264,13 +323,14 @@ workbuddy-portal/
├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
├── client.py 云端接口(urllib)+ 编辑器凭证读取
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
├── scheduler.py 进程内调度线程(按账号遍历 + 槽位去重 + 启动补跑)
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份)
├── query.py SQL 聚合层(uid 必填)
├── backup.py 备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复)
└── web/
├── views.py 页面路由(含 /login /register /captcha.png /profile)
├── views.py 页面路由(含 /login /register /captcha.png /profile /backups)
├── api.py JSON API
├── templates/ base / login / register / profile / overview / tasks /
│ config / logs / records / users / error
│ config / logs / records / users / backups / error
└── static/
├── css/app.css 统一设计令牌
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
@@ -278,14 +338,23 @@ workbuddy-portal/
└── 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](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:登录、各页面操作、Cookie 获取、导出、常见操作 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:Docker、裸机、反向代理、备份恢复、升级回滚、推镜像到 Gitea、排错 |
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:注册、登录、配 Cookie、各页面操作、导出、**权限与数据边界**、**信息安全与隐私安全**、常见问题 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:**三条部署路径(裸机 / Docker 自打包 / compose 拉云端镜像)**、反向代理与 HTTPS、推镜像到 Gitea、备份恢复、升级回滚、巡检、排错、配置项速查 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 开发 | 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍 |
| [docs/API.md](docs/API.md) | 开发 / 集成 | 接口参考:路径、参数、返回结构、错误码 |
| [docs/FAQ.md](docs/FAQ.md) | 所有人 | 常见问题:采集为空、Cookie 失效、时区、性能、权限 |
@@ -300,31 +369,66 @@ workbuddy-portal/
## 安全须知
局域网可访问 ⇒ 以下每一条都必要:
局域网甚至公网可访问 ⇒ 以下每一条都必要:
- **必须改默认密码**;给只读同事发普通账号(`is_admin=0`),不要共用管理员。
- **初始口令不是固定的**(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` 分开**,轮换代价不同)。
`get_settings()` 把加密键一律置空,要明文只有 `db.get_secret()` 一条路——
这样任何「顺手打印全部配置」的代码都带不出凭证。升级时历史明文会被自动加密。
`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`,不必等 12 小时会话过期。
- **停用账号立即失效**:`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 分钟;
失败计数表有上限与 TTL;验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
- **登录限速按两个维度、且强度刻意不同**: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`。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、
`data/usage.sqlite`、`logs/`、`.env`(含明文密码)。
**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` 补救只会多留一个含内容的中间层。
---
@@ -345,8 +449,8 @@ Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在
### 参与贡献
- 想改代码?先读 [CONTRIBUTING.md](CONTRIBUTING.md) —— 里面有**必须遵守的几条不变量**
(SQLite 单写者、列表接口不回 `prompt` 全文、两种字段命名契约不要互相「统一」……),
以及从 `compileall` 到容器验证的五层自检该怎么跑。
(SQLite 单写者、列表接口不回 `prompt` 全文、两种字段命名契约不要互相「统一」、
写权限只能走 `config.writable_by`……),以及从 `compileall` 到容器验证的五层自检该怎么跑。
- 有想法但手上没有真实数据?`python tools/demo_data.py` 会生成一份**完全合成**的示例库,
写到 `data/demo/`(已在 `.gitignore` 内),可直接拿来调试界面与截图。
- 发现安全漏洞?**请不要开公开 Issue**,按 [SECURITY.md](SECURITY.md) 走私有渠道。
@@ -359,11 +463,20 @@ Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在
Cookie 是 `deadbeef…` / `cafef00d…` 这类一眼可辨的假串,
审计 IP 取自 RFC 5737 的文档专用网段(`192.0.2.0/24`)。
生成方式是 `tools/demo_data.py`(会造 `admin` 与 `demo` 两个账号,各有自己的数据),
所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(11 张,
含注册页与个人中心),脚本会读示例库里的验证码答案自动过掉登录。
所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(**13 张**,
含注册页、个人中心,以及**普通账号视角的只读「任务管理 / 配置管理」**),
脚本会读示例库里的验证码答案自动过掉登录。
> 重出截图时请用**相对路径**起示例服务(`WB_DATA_DIR=data/demo`):用绝对路径会让启动日志
> 印出 `C:\Users\<用户名>\…`,而那一行正好会出现在「日志管理」页的截图上。
> ⚠️ **重出截图时务必用相对路径起示例服务**:
> ```bash
> 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` 报「需要验证码但取不到答案」)。
### 致谢
+122 -19
查看文件
@@ -2,13 +2,35 @@
## 支持范围
本项目按「自托管、局域网内使用」的定位开发。安全修复只针对当前主分支与最新发布版本。
本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、
资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
| 版本 | 是否接受安全修复 |
|---|---|
| `1.2.x`(当前) | ✅ |
| `1.4.x`(当前) | ✅ |
| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) |
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
> **1.4.0 修掉了什么**(三条 P0):
> 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。
> 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF,
> 全站只有 `security.client_ip()` 一个取客户端地址的入口。
> 2. **默认口令 `admin123` 硬编码兜底**:现在没有默认口令,留空则生成随机口令、
> 只在启动日志打印一次、不写进数据库。
> 3. **`.dockerignore` 漏了 `backups/` ⇒ 凭证密文被打进镜像**:`backups/` 里那份
> 4.5 MB 的真实库快照含 `settings` 凭证密文与 `users` 口令散列,
> `docker build` 会把它烤进镜像层,推一次镜像等于把整库密钥分发出去。
>
> 还收掉了一批 P1:采集/导出无跨度上限与频率限制、`WB_COOKIE_SECURE` 默认关且无 HSTS、
> 账号锁定可被当武器(管理员用户名公开)、撞库不受限、验证码强度不足(可模板匹配)、
> 改密码不失效其他会话、`instance.json` 未设 `0600`、无访问日志。
> **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以
> 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验);
> 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留
> 「维护本人 Cookie / User-Agent」这一项写权限。
## 如何报告漏洞
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key` / `cookie_key`、可复现的绕过步骤)。
@@ -37,16 +59,26 @@
| 项 | 做法 | 位置 |
|---|---|---|
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
| 角色 | 管理员 / 普通两档;`/users`、`/logs/tail`、`vacuum` 等仅管理员 | `security.admin_required` |
| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` |
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己 | `web/api.py` |
| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`、`/backups`、`/api/backups*`(含下载与**恢复**) | `security.admin_required` |
| **写权限只有一条规则** | `config.writable_by(key, is_admin)` —— 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人那份)。页面与接口共用这一个判断,杜绝「界面置灰但接口还能写」 | `config.writable_by` |
| **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` |
| 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` |
| 停用即失效 | `current_user()` **每个请求**回查 `users.status` **与 `users.session_ver`**,不等 12 小时会话过期 | `security.current_user` |
| **会话版本号** | 改密码 / 管理员重置口令 / 停用 / **删除**账号 → `bump_session_ver()`,该账号所有其他设备上的会话**立刻作废**。改自己密码时把当前会话刷到新版本(否则改完立刻被自己踢下线)。**恢复备份**会让全站所有会话作废 | `db.session_ver_of` / `db.bump_session_ver` |
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` |
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` |
| 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` |
| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名) | `security.hash_password` / `password_problem` |
| **客户端 IP 唯一入口** | `security.client_ip()` —— 默认**不信任** `X-Forwarded-For`(直接用 `remote_addr`);`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP(最近一跳由你自己的代理写入,客户端伪造不了)。含 `IPv4:port` 与 IPv6 处理。**所有** IP 相关判断(验证码限速 / 注册配额 / 登录限速 / 审计留痕)都走它 | `security.client_ip` |
| **强制 HTTPS** | `WB_FORCE_HTTPS=1` 时非 HTTPS 的 **GET/HEAD** 跳转(POST 不跳,否则会掉请求体);`WB_TRUST_PROXY` 关闭时不读 `X-Forwarded-Proto` | `security.needs_https_redirect` |
| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名)+ **黑名单** `WEAK_PASSWORDS`(34 个撞库字典头几页)。黑名单**只在设置/修改口令时生效**,登录不校验 —— 否则会把用老口令的存量用户挡在门外 | `security.hash_password` / `password_problem` |
| **初始口令不给默认值** | `WB_ADMIN_PASSWORD` 留空 → `secrets.token_urlsafe(12)` 随机生成,**只在启动日志打印一次、不写进数据库**(审计里出现口令等于永久留档) | `db._create_first_admin` |
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
| 失败限速 | **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次锁 10 分钟;计数表有上限与 TTL | `security.auth_locked` / `note_auth_fail` |
| 失败限速(双维度、**强度刻意不同**) | **IP 维度**:连续失败 5 次**真锁** 10 分钟。**用户名维度**:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而**管理员用户名在导航里是公开的**。另有**单来源登录尝试总量** 40 次 / 5 分钟(**含成功**),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL | `security.auth_block_reason` / `note_try` / `try_window_left` |
| 失败提示不泄漏信息 | 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 | `web/views.py:login` |
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
| **HSTS 只在真 HTTPS 时下发** | `COOKIE_SECURE or FORCE_HTTPS` 才发 `Strict-Transport-Security`。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 | `security.apply_security_headers` |
| **访问日志** | waitress 自己不记 access log;本程序补上(跳过 `/static/` 与 `/captcha.png`),写进 `logs/app.log`,`WB_ACCESS_LOG` 控制 | `security._access_log` |
### 多用户数据隔离
@@ -54,10 +86,14 @@
|---|---|---|
| 强隔离 | `uid` 是 `conn` 之后的**第一个位置参数且无默认值**;漏传直接 `TypeError`,不会退化成「返回全量」 | `query.py` / `collect.py` / `scheduler.py` |
| 按 id 取单条也隔离 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` | `web/api.py` |
| 配置作用域 | 三级回落 `个人 → 实例(user_id=0) → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 | `db.get_settings`、`config.GLOBAL_KEYS` |
| **配置作用域与写权限对齐** | 「键存在哪一级」与「谁能改」是一件事:`GLOBAL_KEYS` 一律存**实例级 `user_id=0`** 且仅管理员可写;`USER_EDITABLE_KEYS` 是个人级且人人可写本人那份。`set_setting()` 强制把全局键重定向到 `user_id=0`,从结构上消除「管理员改了只有自己生效」 | `config.GLOBAL_KEYS` / `config.USER_EDITABLE_KEYS`、`db.set_setting` |
| 三级回落 | `个人 → 实例(user_id=0) → DEFAULTS` | `db.get_settings` |
| 凭证不回落 | `NO_FALLBACK_KEYS = {cookie, user_agent}` **不参与实例级回落** —— 回落等于新账号继承管理员凭证,是最严重的串号越权 | `db.get_setting` |
| 日志隔离 | 采集运行记录按账号下发;`/logs/tail`(应用日志文件)仅管理员 | `web/views.py` |
| 实例级也不存凭证 | `init_db()` 灌默认值时跳过 `USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')` —— 实例级存凭证等于给所有账号发同一张身份 | `db.init_db` |
| 实例级不是孤儿 | `user_id=0` 不对应任何真实账号,**任何「清理孤儿行」的语句都必须排除它**(历史缺陷:`DELETE ... WHERE user_id NOT IN (SELECT id FROM users)` 曾把实例级配置整片删掉) | `tools/smoke.py` 防回归断言 |
| 日志隔离 | `/logs`、`/logs/tail` **仅管理员**;操作审计对普通账号只下发本人记录 | `web/views.py`、`web/api.py` |
| 导出不互相覆盖 | `/records/export` 与 CLI `export-csv` 的文件名带账号名 | `web/views.py`、`collect.export_csv` |
| 前端不误提交只读项 | `app.js:formData()` 跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端按「越权修改只读项」拒掉**整单** | `web/static/js/app.js` |
### 云端凭证(Cookie)的保密
@@ -69,53 +105,120 @@
| 只回掩码 | 页面与 `/api/settings` 只给「N 字符,结尾 …xxxx」与 `broken` 标志,`secret_state()` 不返回明文 | `db.secret_state` |
| 失败即报错 | `decrypt()` 校验失败**抛 `DecryptError`**,绝不「失败就返回原值」;非 `v1.` 前缀视为历史明文原样返回(下次写入自动升级) | `crypto.decrypt` |
| 历史明文清理 | 启动迁移时把 settings 里残留的明文凭证就地加密,并写一条 `encrypt_secrets` 审计 | `db._encrypt_legacy_secrets` |
| TLS 校验 | 默认开启,**不提供「关掉校验」的快捷开关**(Cookie 不该裸奔) | `settings.ssl_verify` |
| TLS 校验 | 默认开启;`ssl_verify` 是**实例级且仅管理员可改** —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 | `settings.ssl_verify`、`config.GLOBAL_KEYS` |
### 防自动化攻击
| 项 | 做法 | 位置 |
|---|---|---|
| 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` |
| **抗模板匹配** | 让**同一字符两次渲染尽量不同**:逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。实测同一验证码两次渲染**字节差异 76.9%**,字符仍可辨认 | `captcha._draw_char` / `captcha._brush_line` |
| 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` |
| 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` |
| 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` |
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径) | `security.captcha_fetch_allowed` |
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭 | `security.register_quota` |
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` |
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` |
### 资源与频率限制(对外提供服务时)
单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。
| 项 | 做法 | 位置 |
|---|---|---|
| **采集三道闸门** | `POST /api/collect`:① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);③ 跨度超上限 → `400` | `web/api.py:api_collect` |
| **采集跨度硬顶** | `COLLECT_MAX_RANGE_DAYS_HARD = 31`(= 1 个月)。`collect_max_range_days` 只是更严的旋钮,**改大也突破不了** —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 | `config.COLLECT_MAX_RANGE_DAYS_HARD`、`collect.max_range_days` |
| 调度时刻数硬顶 | `SCHEDULE_SLOTS_HARD_MAX = 12`,另有可配置的更严上限 `max_schedule_slots_per_day`(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 | `config.normalize_setting` |
| 重操作最小间隔 | 采集 60s / CSV 导出 15s / **导出本人数据 10s** / `vacuum` 120s / 补全 prompt 60s / 重算 5s;超限 `429` 并给出剩余等待秒数 | `security.action_allowed` |
| 容器资源上限 | `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` **与 `mem_limit` 相等**(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库) | `docker-compose.yml` |
| 线程数受控 | `WB_THREADS`(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 | `config.THREADS` |
| SSRF 收口 | `api_base` 拒绝云元数据地址(`169.254.169.254` / `metadata.google.internal` / `[fd00:ec2::254]`)—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 | `config.normalize_setting` |
### 备份与数据导出
| 项 | 做法 | 位置 |
|---|---|---|
| **归档含密钥,这是有意的取舍** | 归档里的 `instance.json` 带 `cookie_key` —— 没有它就永远解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。代价是**归档本身是最高机密**,因此它**永不入库、永不进镜像** | `backup.INSTANCE_NAME` |
| **不进镜像** | `.dockerignore` 排除 `backups/`;Dockerfile 在 `COPY` 之后有一条**构建期断言**,`/app/backups` 非空即构建失败。不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层 | `.dockerignore`、`Dockerfile` |
| 文件名收口 | `safe_name()` 对下载与恢复接口吃进来的文件名做 basename 收敛 + 后缀 + 字符白名单(防 `../../etc/passwd`、绝对路径、`sub/..`) | `backup.safe_name` |
| 防 zip slip | `_extract_safely()` 只按**基名**解压 —— 归档可能是**外部给的**,`extractall` 会因为条目名里的 `..` 写到目录之外 | `backup._extract_safely` |
| 恢复前护栏 | 自动先给**当前**库打一份 `pre-restore` 快照;恢复错了还能回去 | `backup.restore` |
| **恢复后全站会话作废** | 所有账号 `session_ver` +1。光靠「从归档里搬 `session_ver`」不够:在打备份**之前**登录的那个人,Cookie 里的 `sv` 正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被换掉了 | `backup.restore` |
| 导出不含凭证 | `/profile/export` 给 4 份 CSV/JSON 与说明,**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。10 秒限速 | `web/views.py:profile_export` |
| 快照用在线备份 API | `Connection.backup` 而不是 `cp`:按页复制并持有读事务,采集正在写也能拿到一致快照。手工 `cp` 一个 WAL 库可能缺最近一段数据,**而且不报错** | `backup._snapshot` |
### 其它
| 项 | 做法 | 位置 |
|---|---|---|
| 容器权限 | 运行层非 root(uid/gid 1000 `app`) | `Dockerfile` |
| 容器权限 | 运行层非 root(uid/gid 1000 `app`);`init: true` 让 tini 接管 PID 1,`docker stop` 能干净传到 python | `Dockerfile`、`docker-compose.yml` |
| **密钥文件权限** | `data/instance.json` 在 POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 `.pre-restore-<ts>` | `config._instance_init`、`backup._restore_instance` |
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
`data/shots/`(截图里可能有真实账号信息)、`data/demo/`、`backups/`(库快照 = 凭证密文 + 密码哈希)、
`logs/*`、`.env`。
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
**也绝不进镜像**:`.dockerignore` 里 `backups/` 是**硬要求**,理由见上表(P0-3)。
`Dockerfile` 里那条构建期断言就是防「以后有人又把它删了」。
> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。
> 容器里它挂独立卷 `wb_backups`;**绝不用绑定挂载**(Windows 9p 下容器会
> `unable to open database file`,且不自愈)。
## 已知的**非**目标(部署方需自行处理)
本项目刻意不做下面这些,请按你的环境补齐:
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。
**反代配置有一个要点**:开启 `WB_TRUST_PROXY=1` 后,程序取 `X-Forwarded-For` 里
**最右侧**的合法 IP —— 最右侧是离你最近的那一跳(由你自己的代理写入),客户端加不进去。
所以 nginx 写 `$proxy_add_x_forwarded_for`(保留链路,便于排查)或 `$remote_addr`
(覆盖)**都安全**;真正不能做的是去信最左边那一段(那是客户端自己填的)。
不设反代直接暴露时**必须保持 0**,否则三道 IP 防线全部失效(见 P0-1)。
- **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。
应用层已有采集/导出/恢复等重操作的最小间隔,但那只是「别自己把自己打满」,
挡不住分布式来源。
- **备份策略只做到「本机自动 + 手动下载」**:程序会按周期打快照、按份数清理、支持下载与恢复,
但**不会**把归档推到异地。**备份留在同一台机器上只防「改错了」,不防「机器没了」** ——
请自行把归档同步到别处(那是 3-2-1 原则里属于你的那一半)。
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
- **不建议直接暴露到公网**:设计前提是局域网或 VPN 内使用。
- **不建议在没有任何前置防护时直接暴露到公网**:1.4.0 起 IP 来源、限速、资源上限、
采集跨度硬顶都补上了,但设计前提仍是「局域网或 VPN 内使用 + 前面有反代」。
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
**备份归档里也有一份 `instance.json`** —— 归档的保密等级与 `instance.json` 完全相同。
- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志,
理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 ——
界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。
**管理员还能下载备份与执行恢复** —— 这等于对全库数据的完整读写权,授管理员前请想清楚。
## 部署前的最小检查清单
- [ ] 已修改默认管理员口令(`WB_ADMIN_PASSWORD`),不再是 `admin123`
- [ ] **未设 `WB_ADMIN_PASSWORD` 时已从启动日志抄下随机初始口令并改掉**
(`docker compose logs portal | grep -A6 管理员初始口令`)—— 现在**没有** `admin123` 兜底
- [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip`
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥)
- [ ] `data/`、`logs/`、`backups/` 的权限只对服务账号可读写(前两者与 `instance.json` 同级机密)
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1`
- [ ] **反代是否重写了 `X-Forwarded-For`**:是 → `WB_TRUST_PROXY=1`
(`$proxy_add_x_forwarded_for` 或 `$remote_addr` 均可,程序取最右侧);
否(含直接暴露)→ **保持 `WB_TRUST_PROXY=0`**。这条搞错会让三道 IP 防线同时失效
- [ ] 容器资源上限符合预期(`docker stats` 看 `MEM LIMIT` 是否为 512MiB;`docker inspect` 看 `PidsLimit`)
- [ ] 确认 `data/instance.json` 没有被提交到任何仓库
- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待
- [ ] 升级到 1.2.0 后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.zip`)
- [ ] **确认镜像里没有备份归档**:`docker run --rm <镜像> sh -c 'ls -A /app/backups'` 应为空
- [ ] 已规划**异地**备份:程序只负责本机打快照,归档需自行同步到别的机器/对象存储
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人(含**下载备份与恢复**)才给管理员
- [ ] 已把「采集最小间隔 / 单次最长跨度 / 每日时刻上限」三个刹车调到符合你的预期
(「任务管理」页可改;跨度硬顶 31 天不可突破)
- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
「已保存但无法解密」
- [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**,
且访问 `/backups` 返回 403(用普通账号各试一次)
+91
查看文件
@@ -0,0 +1,91 @@
# backups/ — 数据库备份存放处
人工或脚本产生的数据库快照统一放这里。**不要放进 `data/`**,原因见下。
## 为什么备份不放在 `data/`
`data/` 是运行时数据目录,在 Docker 部署下会被挂载成卷(`workbuddy-portal_wb_data`)。
- 备份放进 `data/` → 执行 `docker compose down -v` 或重建卷时,**备份会跟着正本一起被删掉**,
这正好是最需要备份的那一刻。
- 备份放在仓库目录下的 `backups/` → 卷重建不影响它,同时离源码够近、搬家不丢。
`backups/` 下的一切都被 `.gitignore` 忽略(见仓库根 `.gitignore` 的「数据库备份」段),
**绝不要提交**:快照里含 `settings` 表的加密凭证密文与 `users` 表的密码哈希。
## 目录内容
| 文件 | 说明 |
| --- | --- |
| `usage.sqlite.bak-pre-v13` | v1.2.0 → v1.3.0 迁移(`DB_SCHEMA_VERSION` 2 → 3)前的快照。保留用意:迁移同时做了「配置作用域收敛」(把调度与采集参数从个人级提升到实例级 `user_id=0`),万一收敛结果不符合预期,可回滚到这份 uv=2 的库重来。 |
### 校验记录(2026-09-16)
```
integrity_check : ok
user_version : 2
usage_records : 1665 条
SUM(credits) : 8513.36
```
迁移后正本 `data/usage.sqlite`(uv=3)同样是 **1665 条 / 8513.36 积分**,零丢失。
> **快照本身是自包含的**:全部数据都在主文件里(`-wal` 为 0 字节,没有未落盘的提交帧)。
>
> 但要注意一个会反复出现的现象:**只要有人以 WAL 模式打开过这份快照,
> SQLite 就会就地重建 `…-wal` / `…-shm` 两个侧车文件** —— 连只读打开也会
> (SQLite 需要 `-shm` 做锁表)。所以「移走一次」不是长久之计,
> 校验命令里加 `immutable=1` 才是根治(告诉 SQLite 这个文件不会变,不必建锁表)。
>
> 侧车只是运行时缓存,`backups/` 已在 `.gitignore` 里被整体忽略,
> 不会误入库。归档或搬运快照前把两个侧车移走即可,前提是先确认 `-wal` 是 0 字节。
## 怎么用
**校验一份备份是否可用**(`immutable=1` = 只读且不建锁表,**不会改动也不会污染快照**):
```bash
python -c "
import sqlite3
c = sqlite3.connect('file:backups/usage.sqlite.bak-pre-v13?immutable=1', uri=True)
print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0])
print('user_version:', c.execute('PRAGMA user_version').fetchone()[0])
print('records:', c.execute('SELECT COUNT(*) FROM usage_records').fetchone()[0])
"
```
> 备份文件的**唯一权威判据**是 `integrity_check` 与行数,别拿 `sha256` 当验签 ——
> 快照落盘后再被 SQLite 干净关闭过一次,主文件字节可能变化而内容完全一致。
> 校验只需 `sqlite3` 标准库,用你跑服务的**同一个**解释器即可。
**回滚一份备份**:停掉服务 → 备份当前正本 → 把快照覆盖回 `data/usage.sqlite`
→ **同时删除 `data/usage.sqlite-wal` 与 `data/usage.sqlite-shm`**(残留的 WAL 会让 SQLite 读到旧状态)
→ 启动服务 → 跑 `python manage.py status` 与 `python manage.py stats` 确认账号与存档条数。
> 回滚前务必确认目标库的 `user_version` 与服务端 `workbuddy_portal/db.py` 的
> `DB_SCHEMA_VERSION` 兼容:回滚到更老的版本号时,服务会在下次启动时重跑迁移。
## 自动备份(可选)
容器部署下推荐用宿主机的 cron 做,**先落盘再压缩**,避免 SQLite 在线拷贝产生撕裂快照:
```bash
# 每天 03:30,用 SQLite 自带的一致性备份命令(不是 cp!)
docker compose exec -T portal python -c "
import sqlite3
src = sqlite3.connect('/app/data/usage.sqlite')
dst = sqlite3.connect('/tmp/wb-backup.sqlite')
src.backup(dst); dst.close(); src.close()
"
docker compose cp portal:/tmp/wb-backup.sqlite \
"./backups/usage-$(date +%Y%m%d).sqlite"
docker compose exec -T portal rm -f /tmp/wb-backup.sqlite
```
`cp` 一个正在被写入的 SQLite 文件可能拿到半截事务;`sqlite3.Connection.backup()`
走的是官方的在线备份 API,能保证快照一致。手工离线拷贝时才可以直接 `cp`。
## 清理策略
保留最近 7 份日备 + 每份迁移前快照。旧的直接删,别在这里堆积——
`backups/` 是安全网,不是归档;数据正本永远只有 `data/usage.sqlite` 一个。
+4
查看文件
@@ -19,3 +19,7 @@ services:
volumes:
- ${WB_HOST_DATA_DIR:-./data}:/app/data
- ${WB_HOST_LOG_DIR:-./logs}:/app/logs
# 备份同样落到宿主机目录(默认就是仓库里的 ./backups)。
# 叠加后 `wb_backups` 那个命名卷变成没人用的空卷,可以随手删掉:
# docker volume rm workbuddy-portal_wb_backups
- ${WB_HOST_BACKUP_DIR:-./backups}:/app/backups
+48
查看文件
@@ -28,6 +28,28 @@ services:
container_name: workbuddy-portal
restart: unless-stopped
init: true # tini 接管 PID 1:docker stop 能干净地传到 python
# ---- 容器资源上限(对外提供服务时的第一道闸门)----
# 应用层已经做了「采集频率 / 并发 / 跨度」三重限制,但那些是**业务**刹车;
# 这里限制的是**进程**本身能吃掉多少宿主机资源 —— 两者都要有:
# 业务刹车管「正常的重活别做太多」,容器上限管的是一次异常(内存泄漏、
# 正则回溯、某次超大导出)能不能把整台机器带下去。
#
# 单写者架构下**不要**靠加副本扛负载,所以「限制单实例资源 + 限制单账号
# 频率」才是正解,而不是横向扩展(见文件头的设计取舍说明)。
cpus: "${WB_CPUS:-1.0}" # 1 核:SQLite 单写者,多给核也并行不起来
mem_limit: "${WB_MEM_LIMIT:-512m}"
memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap,
# 否则内存超限会悄悄滑进 swap,
# 表现为「越来越慢」而不是「被 OOM 杀掉」
pids_limit: ${WB_PIDS_LIMIT:-256} # 挡 fork 炸弹
# 文件句柄:SQLite 会在主库之外再持有 -wal / -shm,备份快照与恢复期间
# 还要同时开临时库 + ATTACH 源库,默认 1024 在高并发下偏紧。
ulimits:
nofile:
soft: 4096
hard: 8192
ports:
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
environment:
@@ -35,16 +57,37 @@ services:
WB_HOST: 0.0.0.0
WB_PORT: "8848"
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
# 留空时程序会生成**随机**口令并只在启动日志里打印一次(不再有 admin123 兜底),
# 所以首次部署要去 `docker compose logs portal | grep 口令` 抄一次。
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
# ---- 反向代理与传输安全(三个必须一起决定,别只改一个)----
# WB_TRUST_PROXY:默认 0 = 不信任 X-Forwarded-For。
# 直接暴露给公网时必须留 0 —— 否则攻击者每次换一个伪造的 XFF,
# 验证码限速 / 注册配额 / 登录锁定三道 IP 防线会同时失效。
# 置 1 的前提:**你自己的**反代会写这个头。开启后程序取 XFF 里
# **最右侧**的合法 IP(最近一跳由你的代理写入,客户端加不进去),
# 所以 nginx 写 $proxy_add_x_forwarded_for(保留链路,便于排查)或
# 写 $remote_addr(覆盖)都可以 —— 关键是别去信最左边那段。
WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0}
# 前面挂了 HTTPS 反代时置 1:读到 X-Forwarded-Proto: https 就不跳转
WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0}
# 会话 Cookie 是否只走 HTTPS。纯 HTTP 部署必须留 0:设成 1 时浏览器
# 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0}
# 访问日志(waitress 自己不记 access log,出事无据可查时很要命)
WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1}
# waitress 线程数 = 单实例能同时吃进几个慢请求(采集/导出/备份恢复)
WB_THREADS: ${WB_THREADS:-4}
WB_BACKUP_DIR: /app/backups
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
volumes:
- wb_data:/app/data # 数据正本 + 导出 + secret_key
- wb_logs:/app/logs # 应用日志(滚动 2 MB × 3)
# 备份**单独一个卷**:备份与正本同卷时,一次 `down -v` 或卷损坏会把
# 两者一起带走 —— 那正是最需要备份的时刻。分开挂才有意义。
- wb_backups:/app/backups
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
healthcheck:
@@ -62,3 +105,8 @@ services:
volumes:
wb_data:
wb_logs:
# 备份卷。**必须与 wb_data 分开** —— 同卷时 `down -v` 会把正本与备份一起删。
# 想让它跟着正本走(比如整套搬到别的机器)就先 `docker run --rm -v
# workbuddy-portal_wb_backups:/b -v $PWD/backups:/o alpine cp -a /b/. /o/`
# 把归档捞到宿主机目录里,再一起搬。
wb_backups:
+7 -2
查看文件
@@ -18,14 +18,19 @@ PORT="${WB_PORT:-8848}"
log() { echo "[entrypoint] $*"; }
log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}"
log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} backups=${WB_BACKUP_DIR:-/app/backups} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}"
# ---------- 1. 初始化(幂等:users 非空时不会重建管理员)----------
# 口令策略(1.4.0 起):WB_ADMIN_PASSWORD 为空时**没有**默认口令,
# manage.py init 会生成一个随机口令并只打印一次 —— 抄下来,否则进不去。
if [ -n "${ADMIN_PASSWORD}" ]; then
python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}"
else
log "未设置 WB_ADMIN_PASSWORD。若这是首次初始化,下面「管理员初始口令」一行就是"
log " 唯一的获取机会(程序不保存明文,库里只有散列,找不回来):"
log " docker compose logs portal | grep -A2 '管理员初始口令'"
log " 已在运行过一次的实例上,这一行不会再出现(管理员早就建好了)。"
python manage.py init --user "${ADMIN_USER}"
log "未设置 WB_ADMIN_PASSWORD —— 首次部署的默认密码是 admin123,请登录后立刻修改"
fi
# ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ----------
+37 -12
查看文件
@@ -214,10 +214,19 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
"scheduler": { "enabled": true, "times": ["09:00","17:00"], "next": "2026-09-14 17:00:00" },
"running_runs": 0,
"last_run": { "id": 8, "trigger": "startup", "status": "ok", "started_at": "...", "message": "..." },
"cookie_set": true
"cookie_set": true,
"is_admin": false,
"can_edit_schedule": false,
"can_view_logs": false
}
```
> 最后三个字段是**前端显隐的依据**(v1.3.0 起)。
> 调度时刻是**实例级**的 —— 普通账号拿到 `can_edit_schedule: false`,
> 页面据此把「采集调度」渲染成只读表格,而不是给一个点了会被拒的表单。
> 大屏是拿不到 Jinja 上下文的静态页,只能靠 `/api/manifest` 的 `role`
> 或这里的字段决定要不要显示「日志管理」入口。
### GET `/api/audit`
操作审计分页。
@@ -255,9 +264,14 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
"cookie_hint": "92 字符,…c0ffee",
"cookie_broken": false,
"user_agent": "Mozilla/5.0 (...)",
"_globalKeys": ["allow_register", "api_base", "api_path",
"captcha_length", "captcha_policy", "register_max_per_ip"],
"_canEditGlobal": true
"_globalKeys": ["allow_register", "api_base", "api_path", "captcha_length",
"captcha_policy", "catch_up", "catch_up_grace_hours",
"drift_tolerance_minutes", "max_prompt", "page_size",
"register_max_per_ip", "rewind_minutes", "schedule_enabled",
"schedule_times", "ssl_verify", "timeout", "verify_days"],
"_userKeys": ["cookie", "user_agent"],
"_canEditGlobal": true,
"_role": "user"
}
```
@@ -267,8 +281,14 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
| `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 |
| `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 |
| `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie |
| `_globalKeys` | 实例级键清单(所有账号共用一份,只有管理员能改) |
| `_globalKeys` | **实例级**键清单(所有账号共用一份,只有管理员能改)。v1.3.0 起含调度与采集参数 |
| `_userKeys` | **个人级**键清单,即 `config.USER_EDITABLE_KEYS`;普通账号唯一能写的两个键 |
| `_canEditGlobal` | 当前账号能否改 `_globalKeys` 里的键 |
| `_role` | `"admin"` / `"user"` —— 前端据此决定显隐(大屏走 `/api/manifest` 的 `role`) |
> **写权限就一条规则**:`config.writable_by(key, is_admin)`。
> 页面上「哪些输入框可以改」与接口「哪些键能写」用的是同一个函数,
> 所以不会出现「界面置灰但接口还能写」的不一致。
### GET `/api/users`(管理员)
@@ -382,19 +402,24 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|---|---|
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
| 非管理员改实例级键 | `400 {"ok": false, "error": "invalid", "errors": ["以下为实例级配置,仅管理员可修改:api_base"]}` |
| 不可写的键(普通账号写实例级) | `400 {"ok": false, "error": "invalid", "errors": ["以下配置仅管理员可修改,本账号无法保存:api_base。普通账号可以维护的是本人凭证(Cookie / User-Agent)。"]}` |
要点:
- **写权限判断只有一处**:`config.writable_by(key, is_admin)`。
普通账号能写的**只有** `cookie` 与 `user_agent`(且只限本人这份);
其余(云端接口、注册策略、调度、采集参数)全部仅管理员。
- **越权写是「整单拒绝」而不是「部分生效」**:请求里只要含一个不可写的键,
整个请求 `400`,并在 `errors` 里**点名**是哪些键。这样调用方不会误以为
「既然 `changed` 里没有它就说明写过了」。
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);写 `__clear__` 或 `-` 才是清空;
- `cookie` 落库前会**自动加密**(`db.set_secret`),写进去的永远不是明文;
- **实例级键**(`_globalKeys`:`api_base` / `api_path` / `allow_register` /
`register_max_per_ip` / `captcha_policy` / `captcha_length`)非管理员**写不了**
—— 否则任意注册用户都能把大家的数据采集指向别的服务器;
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
- 内部键(`slot:*`)被忽略;
- 改了 `schedule_times` / `schedule_enabled` 会清掉**自己**的槽位标记,新时刻立即生效;
- 每次拒绝都会写一条 `settings_rejected` 审计。
- 内部键(`slot:*`)被忽略 —— 它们是调度簿记,不属于用户可配置项;
- 改 `schedule_times` 会清掉**已不存在时刻**对应的 `slot:*` 标记(所有账号一起清),
新时刻立即生效。刻意**不做全清**:全清会让所有账号在宽限期内一起重采。
- 每次拒绝都会写一条 `settings_rejected` 审计(管理员的「日志管理 → 操作审计」里能看到,
这也是排查「谁的账号在试越权」的入口)。
### POST `/api/password`
+42 -9
查看文件
@@ -88,8 +88,13 @@ audit_log(id, user_id, at, actor, action, detail, ip)
```
**`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示
**实例级**(所有账号共用,例如 `api_base`、系统迁移审计);在 `usage_records` 里
是「尚未归属」的兜底值,正常不会出现。
**实例级**(所有账号共用,例如 `api_base`、`schedule_times`、`page_size`、系统迁移审计);
在 `usage_records` 里是「尚未归属」的兜底值,正常不会出现。
> ⚠️ 实例级**不是**「孤儿行」。清理孤儿数据的 SQL 必须显式排除 `user_id = 0`,
> 例如 `DELETE FROM settings WHERE user_id <> 0 AND user_id NOT IN (SELECT id FROM users)`。
> 写成 `user_id NOT IN (SELECT id FROM users)` 会一次性删光整片实例级配置 ——
> 表现是「所有账号的调度、采集参数、注册策略突然全部回到默认值」。
索引全部以 `user_id` 打头,覆盖六类热点查询:
`(user_id, day)`、`(user_id, day, hour)`、`(user_id, model, day)`、`(user_id, client, day)`、
@@ -298,7 +303,12 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt)
### 授权与数据隔离
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
`@admin_required`(403)两层。`/users`、`/api/users*`、`/logs/tail`、`vacuum` 要管理员。
`@admin_required`(403)两层。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`。
两者之间还有一层「同一个页面、两种形态」:`/tasks` 与 `/config` 对普通账号**仍然可达**,
但渲染成**只读形态**(不给表单,换成只读表格 + 一句说明为什么只读),
写接口也会拒绝。页面形态与接口判断**共用 `config.writable_by()`**,
所以不存在「界面上没按钮、构造请求却能改」的空隙 —— 这是本次权限收敛刻意保证的性质。
**多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计:
@@ -319,7 +329,7 @@ scheduler.slots(conn, uid=0)
| 项 | 做法 |
|---|---|
| 单条读取也过滤 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` |
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 |
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS`(云端接口 + 注册策略 + 调度 + 采集参数)一律存**实例级 `user_id=0`** 且仅管理员可写;普通账号可写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}` |
| **凭证不回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` 跳过实例级回落 —— 否则新账号会「继承」管理员的 Cookie,这是最严重的串号越权 |
| 导出隔离 | CSV 文件名带账号名(多用户下同目录同名会互相覆盖) |
| 审计归属 | `audit_log` / `collect_runs` 都带 `user_id`;`/api/audit` 普通账号只看自己 |
@@ -421,9 +431,30 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认
└── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级
```
`GLOBAL_KEYS`(`api_base` / `api_path` / `allow_register` / `register_max_per_ip` /
`captcha_policy` / `captcha_length`)在读写两侧都被强制折算到 `user_id = 0`,
所以它们天然只有一份,非管理员改不了。
`GLOBAL_KEYS`(共 17 个键,分四组)在读写两侧都被强制折算到 `user_id = 0`,
所以它们天然只有一份,非管理员改不了:
| 分组 | 键 | 为什么归实例级 |
|---|---|---|
| 云端接口(2) | `api_base`、`api_path` | 一台部署连的就是那一个云端,逐账号配没有意义 |
| 注册策略(4) | `allow_register`、`register_max_per_ip`、`captcha_policy`、`captcha_length` | 敞开注册与否是运营决策,不能由任意账号开关 |
| 调度(4) | `schedule_enabled`、`schedule_times`、`catch_up`、`catch_up_grace_hours` | **普通账号不能设置定时任务频率**(本轮需求) |
| 采集参数(7) | `page_size`、`rewind_minutes`、`drift_tolerance_minutes`、`max_prompt`、`verify_days`、`timeout`、`ssl_verify` | 关掉 `ssl_verify` 就能把所有人的 Cookie 发到中间人手里 |
这三样东西必须**对齐**,缺一不可:
1. **键存在哪一级** —— `GLOBAL_KEYS` 一律 `user_id = 0`;
2. **谁能写** —— `config.writable_by(key, is_admin)`:只有 `USER_EDITABLE_KEYS` 对普通账号放行;
3. **读时落哪** —— 三级回落 `个人 → 实例 → DEFAULTS`。
> 🔴 最常见的静默 bug 是「只做到第 2 条」:管理员能改,但值写进了**管理员自己的 `user_id=n`**。
> 别的账号读同一个键时会**落回 `DEFAULTS`**,于是「管理员改了,但只有他自己那边生效」,
> 界面上完全看不出来。所以 `set_setting()` 内部会把全局键**强制重定向**到 `user_id = 0`,
> 从结构上消除这种可能,而不是靠调用方记得传 `0`。
有个 **`slot:*` 例外**:`slot:09:00` 这类簿记键表示「**本账号**今天这个槽位跑过没有」,
它是**个人级**(每个账号各记一份),而它所指向的时刻 `schedule_times` 是**实例级**。
这两者千万别一起改 —— 把 `slot:*` 也搬到实例级,会让两个账号互相以为对方已经跑过当天采集。
有个**容易误判**的细节:`NO_FALLBACK_KEYS` 只拦住「实例级那一行」,不拦 `DEFAULTS`。
所以一个全新账号读 `user_agent` 拿到的是 `DEFAULTS` 里的**通用 Chrome UA**(非空),
@@ -513,13 +544,15 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
| 层 | 手段 | 抓什么 |
|---|---|---|
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
| 2 | `tools/smoke.py`(**165 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(**83 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头 |
| 2 | `tools/smoke.py`(**215 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、**非管理员越权面全关死**、**全局键必须落在实例级**、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(**122 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头;`--as 账号:密码` 追加普通账号越权验收 |
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
| 附 | `tools/check_docs.py`(**文档层**,不属于上面五层) | 内部链接 / 跨文件锚点 / 图片引用 / **绝对路径泄漏** / 版本一致性 / 产品名硬编码。**章节一重排,锚点就静默失效**,Markdown 自己不报错,只有它抓得到。文档清单自动发现,不写死文件名 |
```bash
python tools/smoke.py # 1~2 层,随时跑
python tools/check_docs.py # 文档层,改过 md 就跑
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
+277 -1
查看文件
@@ -8,6 +8,282 @@
---
## [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 加密 · 开放注册与验证码**
@@ -159,7 +435,7 @@
| 容器跑着跑着页面全 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#windows-绑定挂载的坑容器打不开数据库)。
已写进 [DEPLOYMENT.md 第十一节](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### 安全
+949 -331
查看文件
文件差异内容过多而无法显示 加载差异
+50 -11
查看文件
@@ -74,7 +74,7 @@ docker compose exec portal python manage.py stats
```
完整复现步骤与原理见
[部署与运维指南](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
[部署与运维指南](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### Q:`database is locked` / `disk I/O error`
@@ -104,7 +104,7 @@ python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.r
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
Cookie 过期。重新获取(见 [用户手册 4.2](USER-GUIDE.md#42-拿-cookie-的两种办法)),
填进「配置管理 → **我的云端凭证**」,保存后按区间补采。
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
@@ -350,7 +350,7 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data .
```
恢复见 [部署指南 6.3](DEPLOYMENT.md#63-恢复)。
恢复见 [部署指南 8.3](DEPLOYMENT.md#83-恢复)。
**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
### Q:数据库文件越来越大
@@ -372,10 +372,33 @@ docker compose exec portal python manage.py vacuum
`明文凭证已加密:settings[uid=1].cookie`;
3. 建 `captchas` 表、给各表补 `user_id` 列与索引。
### Q:从 1.2.0 升级到 1.3.0 要做什么
**同样零手工动作**,但会做一次**配置作用域收敛**,值得知道它改了什么:
| 做了什么 | 效果 |
|---|---|
| 把管理员个人名下的**调度与采集参数**提升到实例级(`user_id=0`) | 这些策略从此「一台部署一套」,对所有账号统一生效 |
| 清掉这些键在 `user_id<>0` 下的残留 | 不会再有「某个账号偷偷带着一份自己的旧调度时刻」 |
| 实例级不再保留 `cookie` / `user_agent` | 凭证只属于个人;实例级存凭证等于给所有账号发同一张身份 |
审计里会留一条 `promote_global_settings`,日志里能看到「配置作用域收敛」。
迁移**幂等**,可重复启动。
**升级后建议确认两件事:**
1. 各账号的 Cookie 仍然「已配置」(能解密)——「配置管理 → 我的云端凭证」;
2. 用一个普通账号登录,「任务管理」里的调度时刻应当是**只读**的,
点保存会被拒并点名越权项。
> 如果你的旧部署里**不同账号原本设了不同的采集时刻**,升级后会统一成管理员那一刻。
> 这是刻意的(一台部署一个时刻表,避免多个采集抢同一把写锁),
> 但需要提前知会使用者:他们之后要改时刻得找管理员。
看迁移结果:
```bash
docker compose logs portal | grep -i migrate
docker compose logs portal | grep -iE "迁移|migrat|收敛|promote"
docker compose exec portal python manage.py users
docker compose exec portal python manage.py stats
```
@@ -391,14 +414,25 @@ docker compose exec portal python manage.py stats
> 迁移用 `PRAGMA user_version` 记录版本,**幂等**:重复启动不会重复迁移。
> 改列这种操作现在也由 `init_db()` 自动完成,不再是「需要手工迁移」。
> 想确认当前库结构版本:
> ```bash
> docker compose exec portal python -c "
> from workbuddy_portal import db; c = db.connect()
> print('user_version =', c.execute('PRAGMA user_version').fetchone()[0])"
> ```
> 1.2.0 是 `2`,1.3.0 是 `3`。
### Q:日志在哪、怎么滚动
| 位置 | 内容 |
|---|---|
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) |
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
| 位置 | 内容 | 谁能看 |
|---|---|---|
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) | 能登服务器的人 |
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 | 能登服务器的人 |
| 页面「日志管理」 | 全实例采集逐行日志 + 应用日志尾部 + 操作审计 | **仅管理员** |
| 页面「任务管理 → 运行历史」 | **你自己**的采集记录(触发方式、耗时、条数) | 所有登录用户 |
> 普通账号看不到「日志管理」页(导航里不显示,直接敲 `/logs` 返回 403)。
> 自己排错先用「任务管理 → 运行历史」,需要逐行日志时找管理员。
### Q:想改采集的接口地址(走镜像/代理)
@@ -415,13 +449,18 @@ docker compose exec portal python manage.py stats
**五层,前两层必须跑绿**:
```bash
python tools/smoke.py # 离线回归 165 项(不需要起服务)
python tools/smoke.py # 离线回归 215 项(不需要起服务)
python tools/check_docs.py # 文档自检(改过任何 md 就跑)
python tools/demo_data.py # 可选:造一份合成示例库
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 83 项
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 122 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
```
> 对**示例库**跑 `check_live.py` 时记得加 `--db data/demo/usage.sqlite` ——
> 它的 `--db` 默认值是真实的 `data/usage.sqlite`,不传会从错误库里取验证码答案,
> 症状是「登录失败」加一串看不懂的断言失败。
两个新增参数值得一提:
- `check_live.py --db <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录;
+533 -219
查看文件
文件差异内容过多而无法显示 加载差异
二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 336 KiB

之后

宽度:  |  高度:  |  大小: 334 KiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 468 KiB

之后

宽度:  |  高度:  |  大小: 643 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 535 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 418 KiB

之后

宽度:  |  高度:  |  大小: 810 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 509 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 382 KiB

之后

宽度:  |  高度:  |  大小: 708 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 638 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 487 KiB

之后

宽度:  |  高度:  |  大小: 833 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 458 KiB

之后

宽度:  |  高度:  |  大小: 498 KiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 476 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 476 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 339 KiB

之后

宽度:  |  高度:  |  大小: 383 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 386 KiB

之后

宽度:  |  高度:  |  大小: 469 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 689 KiB

+152 -3
查看文件
@@ -28,6 +28,9 @@
python manage.py passwd <用户名> --role admin 新建或提权为管理员
python manage.py status 查看各账号的调度与最近采集状态
python manage.py vacuum 整理数据库(checkpoint + VACUUM)
python manage.py backup 立即打一份备份(所有 *.sqlite + instance.json)
python manage.py backups 列出备份;--prune 按保留份数清理最旧的
python manage.py restore <文件名> --yes 从备份恢复(恢复前会自动再备份一份当前库)
"""
import argparse
import os
@@ -35,7 +38,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402
from workbuddy_portal import backup, client, collect, config, db, query, scheduler # noqa: E402
from workbuddy_portal import security # noqa: E402
@@ -102,6 +105,19 @@ def cmd_init(args):
_p(" 账号数:%d" % db.user_count(conn))
_p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 操作账号:%s" % args.user)
# 随机口令只在「本次进程刚生成」时返回 —— 重跑 init 时管理员已存在,这里是 None。
# 必须**明着打印**:库里只有散列,日志一旦滚掉就再也拿不回来了。
pwd = db.generated_admin_password()
if pwd:
_p("")
_p(" ================= 管理员初始口令 =================")
_p(" 用 户 名:%s" % args.user)
_p(" 初 始 口 令:%s" % pwd)
_p("")
_p(" 这是随机生成的(1.4.0 起不再有 admin123 这类默认口令)。")
_p(" 程序只打印这一次、数据库里只存散列 —— 找不回来,")
_p(" 请立刻抄走,并在登录后到「个人设置」改掉。")
_p(" ==================================================")
st = db.secret_state(conn, "cookie", uid)
if st["broken"]:
_p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴")
@@ -119,8 +135,11 @@ def cmd_serve(args):
return
try:
from waitress import serve
_p("生产模式(waitress)监听 http://%s:%d" % (host, port))
serve(app, host=host, port=port, threads=8, ident="workbuddy-portal")
# 线程数是资源上限的一部分(见 config.THREADS 的注释):
# 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复)。
_p("生产模式(waitress)监听 http://%s:%d,线程数 %d"
% (host, port, config.THREADS))
serve(app, host=host, port=port, threads=config.THREADS, ident="workbuddy-portal")
except ImportError:
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
app.run(host=host, port=port, threaded=True)
@@ -474,6 +493,120 @@ def cmd_vacuum(args):
return 0
# ---------------- 备份 / 恢复 ----------------
def cmd_backup(args):
"""立即打一份备份。走 SQLite 在线备份 API,采集正在写也安全。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
try:
r = backup.create(conn, trigger="cli", actor="cli", note=args.note or "")
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
_p(r["message"])
_p(" 归档:%s" % backup.path_of(r["filename"]))
_p(" 大小:%s" % backup.human(r["bytes"]))
_p(" 校验:sha256 %s…" % r["sha256"][:16])
_p(" 内容:%d 条记录 / %.2f 积分 / %d 个账号 / 库结构 uv=%s"
% (r["stats"]["records"], r["stats"]["credits"],
r["stats"]["users"], r["stats"]["schema_ver"]))
keep = db.get_int(conn, "backup_keep", 7)
removed = backup.prune(conn, keep=keep, actor="cli")
if removed:
_p(" 已按「保留 %d 份」清理 %d 份最旧的:%s"
% (keep, len(removed), ", ".join(removed)))
_p("")
_p("提示:归档里含 instance.json(SECRET_KEY 与 cookie_key),")
_p(" 权限等同管理员口令 —— 别随镜像 / 仓库分发,也别放进公开网盘。")
finally:
conn.close()
return 0
def cmd_backups(args):
"""列出备份(可选清理)。磁盘是事实来源,每次先重建索引。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
n = backup.sync_index(conn)
rows = backup.listing(conn)
_p("备份目录:%s" % backup.backup_dir())
if not rows:
_p("磁盘上还没有任何归档(%d 份 zip)。" % n)
_p("跑 manage.py backup 打一份;容器里这个目录挂的是独立的 wb_backups 卷。")
return 0
_p("磁盘 %d 份 · 合计 %s" % (n, backup.human(backup.total_bytes(conn))))
_p("")
_p("%-3s %-30s %10s %7s %11s %5s %-11s %s"
% ("#", "文件名", "大小", "条数", "积分", "账号", "来源", "生成时间"))
for i, r in enumerate(rows, 1):
_p("%-3d %-30s %10s %7d %11.2f %5d %-11s %s%s"
% (i, r["filename"], r["size_h"], r["records"], r["credits"],
r["users"], r["trigger"] or "-", r["created_at"],
"" if r["exists"] else " [文件已不存在]"))
enabled = db.get_bool(conn, "backup_enabled", True)
every = db.get_int(conn, "backup_interval_hours", 24)
keep = db.get_int(conn, "backup_keep", 7)
nxt = backup.next_auto_at(conn)
tail = (",下次约 %s" % nxt.strftime("%Y-%m-%d %H:%M")) if nxt else ""
_p("")
_p("自动备份:%s(周期 %d 小时,保留 %d 份)%s"
% ("启用" if enabled else "停用", every, keep, tail))
if args.prune:
want = args.keep or keep
removed = backup.prune(conn, keep=want, actor="cli")
_p("已按保留 %d 份清理 %d 份:%s"
% (want, len(removed), ", ".join(removed) if removed else "无(本来就不够多)"))
finally:
conn.close()
return 0
def cmd_restore(args):
"""从归档恢复。**破坏性操作**:必须显式加 --yes。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
try:
info = backup.verify(backup.path_of(args.filename))
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
cn, cc = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
cu = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
_p("待恢复:%s" % args.filename)
_p(" 归档生成于 %s(程序 v%s,库结构 uv=%d)"
% (info["created_at"], info["version"], info["schema_ver"]))
_p(" 归档内容:%d 条 / %.2f 积分 / %d 个账号"
% (info["records"], info["credits"], info["users"]))
_p(" 当前正本:%d 条 / %.2f 积分 / %d 个账号" % (cn, cc, cu))
if not args.yes:
_p("")
_p("这会**整表替换** usage_records / settings / users / collect_runs / "
"audit_log / captchas,")
_p("并且所有既有登录会话会立即失效(所有人需要重新登录)。")
_p("恢复前系统会自动把当前库另存一份备份,所以恢复错了还能回来。")
_p("")
_p("确认无误后,重跑并加上 --yes。")
return 1
try:
r = backup.restore(conn, args.filename,
include_instance=not args.no_instance, actor="cli")
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
_p(r["message"])
_p(" 搬运的表:%s" % ", ".join(r["moved"]))
_p(" instance.json:%s"
% ("已一并恢复(cookie_key 换成了归档里那把)" if r["restored_instance"]
else "未动(保留本机当前的密钥)"))
finally:
conn.close()
return 0
def _human(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
@@ -552,6 +685,22 @@ def main():
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
s.set_defaults(func=cmd_vacuum)
s = sub.add_parser("backup", help="立即打一份备份(所有 *.sqlite + instance.json)")
s.add_argument("--note", default="", help="给这份备份写一句备注(记进 manifest 与审计)")
s.set_defaults(func=cmd_backup)
s = sub.add_parser("backups", help="列出备份;--prune 按保留份数清理最旧的")
s.add_argument("--prune", action="store_true", help="顺便清理超出保留份数的旧备份")
s.add_argument("--keep", type=int, default=None, help="保留几份(默认取配置里的值)")
s.set_defaults(func=cmd_backups)
s = sub.add_parser("restore", help="从备份恢复(破坏性操作,必须加 --yes)")
s.add_argument("filename", help="备份文件名(如 usage-20260916-151043.zip,用 backups 查看)")
s.add_argument("--no-instance", action="store_true",
help="不恢复 instance.json(保留本机当前的 secret_key / cookie_key)")
s.add_argument("--yes", action="store_true", help="确认执行(不加只打印将要发生什么)")
s.set_defaults(func=cmd_restore)
args = ap.parse_args()
if not getattr(args, "func", None):
ap.print_help()
+334
查看文件
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""文档自检:内部链接 / 跨文件锚点 / 图片引用 / 绝对路径泄漏 / 版本一致性 / 产品名硬编码。
文档一旦互相引用(README → docs/DEPLOYMENT.md#某节),章节重排就会让锚点**静默失效** ——
Markdown 不会报错,页面只是不跳转、图片只是显示裂图。这个脚本把这类问题变成可执行的断言。
用法:
python tools/check_docs.py # 有问题则退出码 1
python tools/check_docs.py --no-fail # 只看报告,不因问题而失败
退出码:0 通过,1 发现问题。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
# ---------------------------------------------------------------- 常量
# 递归扫描时跳过的目录。**自动发现所有 .md**,而不是写死文件名列表 ——
# 写死列表的版本曾漏掉 THIRD-PARTY-NOTICES.md 与 CODE_OF_CONDUCT.md
# (新增文档时必然漏,而且没人会发现)。
SKIP_DIRS = {
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
}
# markdown 链接:[文本](目标)
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
# markdown 图片:![alt](目标)
IMG_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)\)")
# markdown 标题:# / ## / ... (用于生成锚点)
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
# 显式 HTML 锚点:<a name="x"></a> 或 <a id="x"></a>
HTML_ANCHOR_RE = re.compile(r"<a\s+(?:name|id)=[\"']([^\"']+)[\"']")
# 代码块围栏(三反引号或三波浪线)
FENCE_RE = re.compile(r"^\s*(```|~~~)")
# 绝对路径泄漏:正向白名单写不出来,反着匹配已知模式足够有效。
# 每个模式的**第 1 个捕获组**是「用户名那一段」,用来判占位符。
LEAK_PATTERNS = [
(re.compile(r"[A-Za-z]:[\\/](?:Users|users)[\\/]([^\\/\s\"'`)]+)"),
"用户目录绝对路径"),
(re.compile(r"[A-Za-z]:[\\/]Documents and Settings[\\/]([^\\/\s\"'`)]+)"),
"用户目录绝对路径"),
(re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)/"), "用户目录绝对路径"),
]
# 占位符豁免:`C:\Users\<用户名>\…` 是**良好实践**,不是泄漏。
PLACEHOLDER_RE = re.compile(
r"[<>%*{}]|^\.{2,}$|^[-_]+$"
r"|^(?:user|users|username|user-?name|your-?name|youruser|"
r"用户名|你的用户名|xxx+|yyy+|zzz+|aaa+|example|placeholder|"
r"me|someone|nobody)$",
re.IGNORECASE,
)
# 版本一致性:这四处必须互相一致
VERSION_INIT = os.path.join("workbuddy_portal", "__init__.py")
INIT_VER_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
DOCKERFILE = "Dockerfile"
OCI_VER_RE = re.compile(r'org\.opencontainers\.image\.version\s*=\s*"([^"]+)"')
README_VER_RE = re.compile(r"^\|\s*版本\s*\|\s*v?([0-9][^\s|]*)\s*\|", re.M)
CHANGELOG_VER_RE = re.compile(r"^##\s*\[?v?([0-9][^\s\]—-]*)", re.M)
# 产品名硬编码:应走 config.PROJECT_NAME 等上下文变量,不写进模板/JS
HARDCODE_NEEDLES = ["WorkBuddy Portal", "WorkBuddy 用量"]
HARDCODE_DIRS = [
os.path.join("workbuddy_portal", "web", "templates"),
os.path.join("workbuddy_portal", "web", "static", "js"),
]
# ---------------------------------------------------------------- 基础工具
def read(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
def strip_code_blocks(text: str) -> str:
"""去掉围栏代码块内容 —— 里面的 `#` 不是标题,里面的链接不该被当链接。
用空串占位(保留换行),这样**行号不会错位**,报错才能定位到真实位置。
"""
out, in_fence = [], False
for line in text.splitlines():
if FENCE_RE.match(line):
in_fence = not in_fence
out.append("")
continue
out.append("" if in_fence else line)
return "\n".join(out)
def slugify(text: str) -> str:
"""把标题/锚点文本转成可比对的 key。
**刻意不逐字复刻 GitHub/Gitea 的 slug 算法。** 各家的标点处理规则并不一致
(GitHub 会删 `+`/`:` 却保留 `、`/`:`,而「连续空格是折叠成一个连字符
还是每个空格一个连字符」也随实现而变)。照着某个实现写死,换个托管平台
就会批量误报,反而让真问题淹掉。
这里只保留「有效字符」:小写字母、数字、汉字。标点、空格、连字符**全部丢弃**。
于是 `八、配置系统:写时校验 + 读时兜底` 与链接里的
`八配置系统写时校验--读时兜底` 归一后相等 —— 章节被重命名时,
有效字符会变,锚点仍然照抓不误。
代价:仅标点不同的两个标题会被视为同一锚点(极端罕见,可接受)。
"""
return re.sub(r"[^0-9a-z\u4e00-\u9fff]", "", text.lower())
def is_external(target: str) -> bool:
"""外部链接(http: / mailto: 等)不检查。"""
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target))
def looks_like_placeholder(segment: str) -> bool:
return bool(PLACEHOLDER_RE.search(segment.strip()))
def discover(root: str) -> list[str]:
"""递归找出所有 .md,返回相对 root 的 posix 路径。"""
found: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if fn.lower().endswith((".md", ".markdown")):
rel = os.path.relpath(os.path.join(dirpath, fn), root)
found.append(rel.replace("\\", "/"))
return sorted(found)
_anchor_cache: dict[str, set[str]] = {}
def anchors_of(abs_path: str) -> set[str]:
"""一个 md 文件里所有可跳转的锚点(标题 + 显式 HTML 锚点)。"""
if abs_path not in _anchor_cache:
text = strip_code_blocks(read(abs_path))
got: set[str] = set()
for line in text.splitlines():
m = HEADING_RE.match(line)
if m:
got.add(slugify(m.group(2)))
for m in HTML_ANCHOR_RE.finditer(text):
got.add(slugify(m.group(1)))
_anchor_cache[abs_path] = got
return _anchor_cache[abs_path]
# ---------------------------------------------------------------- 各检查项
def check_links(root: str, files: list[str]) -> list[str]:
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
base_dir = os.path.dirname(abs_path)
text = strip_code_blocks(read(abs_path))
for lineno, line in enumerate(text.splitlines(), 1):
for m in LINK_RE.finditer(line):
target = m.group(2)
if is_external(target):
continue
# 纯页内锚点:指回本文件
if target.startswith("#"):
frag = target[1:]
if frag and slugify(frag) not in anchors_of(abs_path):
problems.append(f"{rel}:{lineno} 页内锚点失效 {target}")
continue
path_part, _, frag = target.partition("#")
if not path_part:
continue
resolved = os.path.normpath(os.path.join(base_dir, path_part))
if not os.path.exists(resolved):
problems.append(f"{rel}:{lineno} 链接目标不存在 {target}")
continue
if frag and resolved.lower().endswith((".md", ".markdown")):
if slugify(frag) not in anchors_of(resolved):
problems.append(f"{rel}:{lineno} 跨文件锚点失效 {target}")
return problems
def check_images(root: str, files: list[str]) -> list[str]:
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
base_dir = os.path.dirname(abs_path)
text = strip_code_blocks(read(abs_path))
for lineno, line in enumerate(text.splitlines(), 1):
for m in IMG_RE.finditer(line):
src = m.group(1)
if is_external(src):
continue
resolved = os.path.normpath(os.path.join(base_dir, src))
if not os.path.exists(resolved):
problems.append(f"{rel}:{lineno} 图片不存在 {src}")
return problems
def check_path_leaks(root: str, files: list[str]) -> list[str]:
"""扫绝对路径 —— 文档里出现多半是从本机命令里抄进来的(会连带泄漏用户名)。
命中后请**逐条人工判断**:占位符(`C:\\Users\\<用户名>`)已豁免,
但 `os.path.join(home, "AppData", ...)` 这类合法的路径发现代码不会命中
(它不含盘符或 `/home/` 前缀)。这里只报告,不自动修改。
"""
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
for lineno, line in enumerate(read(abs_path).splitlines(), 1):
for pat, label in LEAK_PATTERNS:
for m in pat.finditer(line):
seg = m.group(1) if m.groups() else ""
if seg and looks_like_placeholder(seg):
continue
problems.append(f"{rel}:{lineno} {label} {m.group(0)}")
return problems
def check_versions(root: str) -> list[str]:
"""版本号四处(__init__ / Dockerfile / README / CHANGELOG)必须一致。"""
found: dict[str, str] = {}
sources = [
(VERSION_INIT, INIT_VER_RE),
(DOCKERFILE, OCI_VER_RE),
("README.md", README_VER_RE),
(os.path.join("docs", "CHANGELOG.md"), CHANGELOG_VER_RE),
]
for rel, pat in sources:
p = os.path.join(root, rel)
if os.path.isfile(p):
m = pat.search(read(p))
if m:
found[rel.replace("\\", "/")] = m.group(1)
if not found:
return ["版本一致性: 一个版本号都没找到,检查脚本本身"]
if len(set(found.values())) > 1:
detail = "、".join("%s=%s" % (k, v) for k, v in sorted(found.items()))
return ["版本不一致: %s" % detail]
return []
def check_name_hardcode(root: str) -> list[str]:
"""产品名应走上下文变量(config.PROJECT_NAME),不该硬编码进模板/JS。"""
problems: list[str] = []
for d in HARDCODE_DIRS:
full = os.path.join(root, d)
if not os.path.isdir(full):
continue
for dirpath, _dirnames, filenames in os.walk(full):
for fn in filenames:
if not fn.lower().endswith((".html", ".js")):
continue
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, root).replace("\\", "/")
for i, line in enumerate(read(fp).splitlines(), 1):
for n in HARDCODE_NEEDLES:
if n in line:
problems.append(
f"{rel}:{i} 疑似硬编码产品名「{n}」(应走上下文变量)")
return problems
# ---------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser(description="文档自检")
ap.add_argument("--root", default=".", help="仓库根(默认当前目录)")
ap.add_argument("--no-fail", action="store_true",
help="即使发现问题也返回 0(仅用于人工查看报告)")
ap.add_argument("--strict", action="store_true", help=argparse.SUPPRESS)
ap.add_argument("--quiet", action="store_true", help="只打印问题")
args = ap.parse_args()
root = os.path.abspath(args.root)
if not os.path.isdir(root):
print("--root 不是目录: %s" % root)
return 1
files = discover(root)
if not files:
print("没找到任何 .md 文件。--root 是否正确?"
"(注意:Git Bash 的 /tmp/x 交给原生 Python 会变成 C:\\tmp\\x)")
return 1
sections = [
("内部链接与锚点", check_links(root, files)),
("图片引用", check_images(root, files)),
("绝对路径泄漏", check_path_leaks(root, files)),
("版本一致性", check_versions(root)),
("产品名硬编码", check_name_hardcode(root)),
]
total = sum(len(p) for _, p in sections)
if not args.quiet:
print("扫描 %d 个 Markdown 文件:" % len(files))
for rel in files:
print(" - %s" % rel)
print()
for title, problems in sections:
if args.quiet and not problems:
continue
print("=== %s ===" % title)
if problems:
for p in problems:
print(" [!!] %s" % p)
else:
print(" OK")
print()
print("RESULT: %d 处问题" % total)
if total == 0:
print("文档自检全部通过。")
# 默认「有问题就失败」——「报出 7 处问题却返回 0」是个静默无用的陷阱,
# 想只看报告请显式加 --no-fail。
if total and not args.no_fail:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+93 -3
查看文件
@@ -5,18 +5,23 @@
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
与 tests 里用 Flask test_client 的冒烟测试互补——这里验证的是「真的起起来了、
真的能登录、真的能取到数」,适合部署到局域网后随手跑一遍。
与 tools/smoke.py 的分工:smoke 用 Flask test_client 直接渲染模板、不发网络请求;
本脚本确认真的是「起起来了、能登录、能取到数」,适合部署到局域网后随手跑一遍。
用法:
python tools/check_live.py # 默认 http://127.0.0.1:8848
python tools/check_live.py --base http://192.168.1.50:8848 # 换成你的部署主机
python tools/check_live.py -u admin -p 你的密码
python tools/check_live.py --as alice:她的密码 # 额外跑一遍**普通账号**的越权面
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
`--as` 那一节会真的发越权请求(改调度 / 改采集参数 / 读日志),
期望全部被拒;不会创建或删除任何账号,所以请自己先准备一个普通账号。
退出码:0 全通过;1 有失败项(会打印失败清单)。
注意:脚本会读取窗口数据但**不写库**(不触发采集、不改配置),可安全反复运行。
注意:脚本会读窗口数据、会走登录(登录本身会更新 last_login_at),
但**不触发采集、不改任何配置**,可安全反复运行。
"""
from __future__ import annotations
@@ -313,6 +318,10 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
chk("settings 回传个人可写键清单(应为 cookie/user_agent)",
set(stj.get("_userKeys") or []) == {"cookie", "user_agent"},
"%s" % stj.get("_userKeys"))
chk("settings 标明角色", stj.get("_role") == "admin", "%s" % stj.get("_role"))
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
@@ -417,6 +426,74 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
"status=%s" % code)
def run_nonadmin(base: str, timeout: int, db_path: str, user: str, pwd: str) -> None:
"""普通账号的越权面(真实 HTTP 链路,--as 才跑)。
规则只有一条:普通账号**只能维护本人凭证**,其余配置 / 日志 / 用户管理
全部不可达。期望值是 403(页面)与 400(写配置)—— 不是「看得到但改不了」,
更不是「写进去但只对自己生效」。
"""
print("== 12. 普通账号越权面(--as %s) ==" % user)
L = Live(base, timeout, db_path)
st, _, _, _ = L.login(user, pwd)
chk("普通账号登录成功", st in (200, 302), "status=%s" % st)
st, html = L.get("/")
if st != 200 or "概览" not in html:
chk("普通账号登录后能看到概览", False, "status=%s(后续断言已跳过)" % st)
return
chk("普通账号登录后能看到概览", True)
chk("导航不出现「日志管理」", "日志管理" not in html)
chk("导航不出现「用户管理」", "用户管理" not in html)
# 可达页面(都只渲染本人数据)
for p, kw in [("/records", "记录"), ("/tasks", "任务"),
("/config", "配置"), ("/profile", "个人")]:
st, body = L.get(p)
chk("GET %-9s 普通账号=200" % p, st == 200 and kw in body, "status=%s" % st)
st, _ = L.get("/dashboard")
chk("GET /dashboard 普通账号=200", st == 200, "status=%s" % st)
# 不可达:日志与用户管理
for p in ("/logs", "/logs/tail?lines=10", "/users", "/api/users"):
st, _ = L.get(p)
chk("GET %-21s 普通账号=403" % p, st == 403, "status=%s" % st)
# 角色标记:页面之外还有静态页(大屏)与前端要靠它决定显隐
stj = L.jget("/api/settings")
chk("/api/settings _role=user", stj.get("_role") == "user", "%s" % stj.get("_role"))
chk("/api/settings _canEditGlobal=False", stj.get("_canEditGlobal") is False)
mf = L.jget("/api/manifest")
chk("/api/manifest role=user(大屏据此隐掉日志入口)",
mf.get("role") == "user", "%s" % mf.get("role"))
sta = L.jget("/api/status")
chk("/api/status is_admin=False", sta.get("is_admin") is False, "%s" % sta.get("is_admin"))
chk("/api/status can_edit_schedule=False", sta.get("can_edit_schedule") is False)
# 越权写:调度 / 采集参数 / 实例级键 -> 400
csrf = L.form_csrf("/config")
chk("拿到普通账号自己的 CSRF", bool(csrf))
for key, val in (("schedule_times", "23:59"), ("schedule_enabled", "0"),
("page_size", "1000"), ("ssl_verify", "0"),
("api_base", "http://evil.invalid"), ("allow_register", "0")):
st, body = L.post("/api/settings", {key: val}, csrf=csrf, as_json=True)
chk("越权 POST %-16s =400" % key, st == 400, "status=%s" % st)
chk(" └ 且点名 %s" % key, key in body)
# 本人 UA 必须写得进去;写回原值,不给对方留副作用
cur_ua = str(stj.get("user_agent") or "")
st, body = L.post("/api/settings", {"user_agent": cur_ua}, csrf=csrf, as_json=True)
chk("本人 user_agent 可写=200", st == 200, "status=%s %s" % (st, body[:100]))
# 页面只给凭证表单,采集参数与调度都渲染成只读
st, cf = L.get("/config")
chk("配置页有凭证表单", 'id="formCred"' in cf)
chk("配置页无采集参数表单", 'id="formCollect"' not in cf)
chk("配置页无实例级设置表单", 'id="formGlobal"' not in cf)
st, tk = L.get("/tasks")
chk("任务页调度只读(没有保存按钮)", "保存调度配置" not in tk)
chk("任务页标注调度仅管理员可改", "仅管理员可改" in tk)
def main() -> int:
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
ap.add_argument("--base", default="http://127.0.0.1:8848", help="服务地址")
@@ -429,12 +506,25 @@ def main() -> int:
"验证码策略为 always 时用它取答案以完成自动登录;"
"指向不存在的文件则跳过需要验证码的登录")
ap.add_argument("--timeout", type=int, default=20)
ap.add_argument("--as", dest="as_user", default=None, metavar="USER:PASS",
help="额外用一个**普通账号**跑一遍越权验收(第 12 节)。"
"不会创建/删除账号,请自己先备好一个普通账号")
a = ap.parse_args()
db_path = a.db or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
if a.as_user:
if ":" not in a.as_user:
print(" [FAIL] --as 需要写成 用户名:密码")
FAILS.append("--as 参数格式")
else:
nu, np_ = a.as_user.split(":", 1)
try:
run_nonadmin(a.base, a.timeout, db_path, nu, np_)
except Exception as e: # noqa: BLE001
chk("第 12 节执行未抛异常", False, "%s: %s" % (type(e).__name__, e))
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if FAILS:
print("失败项:%s" % "、".join(FAILS))
+4 -3
查看文件
@@ -184,9 +184,10 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
# 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。
db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid)
db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid)
# 两个账号各有一套调度时刻,界面上能看出「每人可改自己的」
db.set_setting(conn, "schedule_times", "09:00,17:00", admin_uid)
db.set_setting(conn, "schedule_times", "08:30,20:00", demo_uid)
# 调度与采集参数是**实例级**的(v1.3.0 起普通账号只读),所以只写一次;
# 这里刻意不按账号各写一份 —— set_setting 会把全局键归到 user_id=0,
# 写两次只会后一次覆盖前一次,看起来「每人一套」其实没有。
db.set_setting(conn, "schedule_times", "09:00,17:00", 0)
rng = random.Random(seed)
now = datetime.now().replace(second=0, microsecond=0)
+60 -19
查看文件
@@ -47,6 +47,15 @@ CAPTURES = [
("06-users.png", "/users", "用户管理", True),
("07-dashboard.png", "/dashboard", "用量大屏", True),
("10-profile.png", "/profile", "个人中心", True),
("11-backups.png", "/backups", "备份管理(仅管理员)", True),
]
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角,
# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。
USER_CAPTURES = [
("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"),
("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"),
]
@@ -118,6 +127,9 @@ def main() -> int:
ap.add_argument("--base", default="http://127.0.0.1:8849")
ap.add_argument("-u", "--user", default="admin")
ap.add_argument("-p", "--password", default="admin123")
ap.add_argument("--user2", default="demo",
help="普通账号用户名(截只读视角用;设为空则跳过)")
ap.add_argument("--password2", default="admin123")
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
@@ -148,38 +160,42 @@ def main() -> int:
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
def shoot(name, path, label):
def shoot(name, path, label, pg=None):
pg = pg or page
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
page.screenshot(path=os.path.join(a.out, name), full_page=a.full)
pg.goto(a.base + path, wait_until="networkidle")
pg.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
pg.screenshot(path=os.path.join(a.out, name), full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %-22s %s%s" % (name, label,
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
def login(pg, ctx, user, password):
"""登录并跨过验证码(策略为 always 时从本地库取答案)。"""
pg.goto(a.base + "/login", wait_until="networkidle")
pg.fill('input[name=username]', user)
pg.fill('input[name=password]', password)
if pg.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
return False
pg.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码(%s)" % user)
pg.click('button[type=submit]')
pg.wait_for_load_state("networkidle")
return "/login" not in pg.url
# 1) 未登录的两页
for name, path, label, need_auth in CAPTURES:
if need_auth:
break
shoot(name, path, label)
# 2) 登录(策略为 always 时自动解验证码)
page.goto(a.base + "/login", wait_until="networkidle")
page.fill('input[name=username]', a.user)
page.fill('input[name=password]', a.password)
if page.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
br.close()
return 1
page.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码")
page.click('button[type=submit]')
page.wait_for_load_state("networkidle")
if "/login" in page.url:
# 2) 以管理员登录(策略为 always 时自动解验证码)
if not login(page, ctx, a.user, a.password):
print("[FAIL] 登录失败,后续截图无意义")
br.close()
return 1
@@ -208,6 +224,31 @@ def main() -> int:
problems.append("大屏交互: %s" % js_err[:2])
break
# 5) 换一个干净 context,用普通账号再跑一趟只读视角
if a.user2:
ctx2 = br.new_context(viewport={"width": a.width, "height": a.height},
device_scale_factor=2, locale="zh-CN")
page2 = ctx2.new_page()
page2.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page2.on("pageerror", lambda e: errors.append(str(e)))
if not login(page2, ctx2, a.user2, a.password2):
print("[warn] 普通账号 %s 登录失败,跳过只读视角截图" % a.user2)
problems.append("普通账号 %s 登录失败" % a.user2)
else:
for name, path, label in USER_CAPTURES:
shoot(name, path, label, pg=page2)
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
# /backups 与 /api/backups 是 v1.4.0 新增的:能下载或恢复整个数据库
# 等于对全库数据有完整读写权,所以必须只给管理员。
for probe in ("/logs", "/logs/tail", "/users", "/backups", "/api/backups"):
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
code = r.status if r else 0
ok = code in (403, 401)
print("[%s] 越权面 %-12s -> %s" % ("ok" if ok else "!!", probe, code))
if not ok:
problems.append("越权面未关死:%s 返回 %s" % (probe, code))
ctx2.close()
br.close()
print("\n截图目录:%s" % a.out)
+257 -18
查看文件
@@ -11,13 +11,18 @@
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见 4. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
3. 历史缺陷防回归(见 5. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
5. CSV 导出可被标准 csv 解析、列数一致
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
权限模型(改断言前先读这一行):
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
调度、采集参数、接口地址、注册策略全部只有管理员能写,且一律存在实例级
`user_id=0`。所以「越权写」的期望结果是 **400**,而不是「写进去但看不到」。
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
不会改动任何用量数据。
@@ -92,6 +97,11 @@ def run() -> None:
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==")
# 先把真实库迁到与代码同版本。init_db 是幂等的,顺带把迁移路径也验一遍。
# 少了这一步,「库还停在旧 schema」会被 current_user() 判成「未登录」,
# 现场表现是下面所有页面断言集体变 302 —— 看起来像产品坏了,
# 其实只是前置条件没满足。
db.init_db()
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
@@ -100,6 +110,9 @@ def run() -> None:
# 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect()
uv = conn.execute("PRAGMA user_version").fetchone()[0]
chk("库 schema 已与代码同版本", uv == db.DB_SCHEMA_VERSION,
"uv=%d 期望=%d" % (uv, db.DB_SCHEMA_VERSION))
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone()
if admin_row is None:
@@ -107,6 +120,17 @@ def run() -> None:
return
ADMIN = admin_row["id"]
# 实例级配置快照:整轮跑完必须一条不少。
# 历史缺陷:清理语句写成 `user_id NOT IN (SELECT id FROM users)`,会把
# user_id=0(实例级)当成孤儿一起删掉 —— 表现为「跑一次 smoke,全实例的
# 调度/采集参数被重置」,而且不报任何错。所以这里前后各取一次快照。
inst_before = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("实例级配置非空(否则下面那条断言会空转)", len(inst_before) > 0,
"keys=%d" % len(inst_before))
chk("实例级不含凭证类键(cookie/user_agent 恒为个人级)",
not (inst_before & config.USER_EDITABLE_KEYS),
"混入=%s" % sorted(inst_before & config.USER_EDITABLE_KEYS))
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
@@ -241,15 +265,31 @@ def run() -> None:
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
saved_ua_inst = db.get_setting(conn, "user_agent", "", 0)
# 注意:get_setting 在没有行时会回落到 DEFAULTS,所以「还原是否成功」
# 要拿**行为**比(读出来一样),而不是拿「行在不在」比 —— 这两件事
# 在实例级是分开的,混起来会让断言永远失败。
before_ua = db.get_setting(conn, "user_agent", "", 0)
had_row = conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None
saved_ua_inst = before_ua if had_row else None
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
chk("② 实例级放哨兵后,新账号仍看不到它",
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
db.get_setting(conn, "user_agent", "", 0) == sentinel)
db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原
chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst)
# 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键
# (v1.3.0 起 cookie / user_agent 恒为个人级),写回空串只会留下
# 一个多余行,下次跑就会让「实例级不含凭证键」的断言失败。
if had_row:
db.set_setting(conn, "user_agent", before_ua, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
chk("② 已还原实例级 UA(读出来与放哨兵前一致)",
db.get_setting(conn, "user_agent", "", 0) == before_ua)
chk("② 还原后实例级不留 user_agent 行(原本有则保留)",
(conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None) == had_row)
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
chk("② 普通配置仍回落实例级",
db.get_setting(conn, "page_size", None, VIEWER) ==
@@ -362,13 +402,22 @@ def run() -> None:
chk("⑦ totals(uid=0) 不含任何人的数据",
query.totals(conn, 0)["records"] == 0)
finally:
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)。
# 原本实例级没有这一行时,**删掉**而不是写回空串 —— 见第 ② 条断言。
if saved_ua_inst is not None:
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
for name in created:
conn.execute("DELETE FROM users WHERE username=?", (name,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)")
# 注意 `user_id<>0` 不能省:user_id=0 是**实例级配置**(调度、采集参数、
# 接口地址、注册策略都在那里),它不属于任何账号,所以
# `NOT IN (SELECT id FROM users)` 会把它当孤儿一起删掉 ——
# 表现成「跑一次 smoke,全实例的配置被重置」,且不报任何错。
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# 确认清理干净
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
@@ -376,7 +425,10 @@ def run() -> None:
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
# ---------------- 4. 普通账号的权限边界 ----------------
print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==")
# 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent,
# 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled
# 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。
print("== 4. 非管理员:越权面必须全部关死 ==")
viewer2 = "smoke_w_%s" % _rand()
try:
V2 = _mk_user(viewer2)
@@ -386,18 +438,112 @@ def run() -> None:
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# 日志尾部是管理员专属
# ④ 日志是**实例级**运行信息(含数据库路径 / 账号名 / 来源 IP),
# 普通账号整页 403 —— 不是「只看到自己那份」。
st, _ = page(cli, "/logs")
chk("GET /logs 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/logs/tail?lines=10")
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
# ⑤ 其余页面(都只渲染本人数据)必须照常能开
for p in ("/", "/dashboard", "/records", "/tasks", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-11s 非管理员=200" % p, st == 200, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("概览导航不含「日志管理」", "日志管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
# ⑥ 越权写:调度 / 采集参数 / 实例级键,逐个试,全部必须 400
keep_times = db.get_setting(conn, "schedule_times", "", 0)
for key, val in (("schedule_times", "23:59"),
("schedule_enabled", "0"),
("catch_up", "0"),
("page_size", "1000"),
("timeout", "300"),
("ssl_verify", "0"),
("max_prompt", "0"),
("api_base", "http://evil.invalid"),
("allow_register", "0")):
st, body = page(cli, "/api/settings", method="POST", json={key: val},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 越权写 %-16s =400" % key, st == 400, "status=%s" % st)
chk(" └ 报错里点名 %s" % key, key in body)
chk("⑥ 越权尝试确实没落库(schedule_times 未变)",
db.get_setting(conn, "schedule_times", "", 0) == keep_times)
chk("⑥ 实例级 api_base 未被改写",
"evil" not in db.get_setting(conn, "api_base", "", 0))
# ⑦ 但本人凭证必须写得进去(否则普通账号根本没法采集)
st, _ = page(cli, "/api/settings", method="POST",
json={"user_agent": "SMOKE-VIEWER-UA/1.0"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑦ 普通账号写本人 user_agent=200", st == 200, "status=%s" % st)
chk("⑦ 且只写进了自己名下",
db.get_setting(conn, "user_agent", "", V2) == "SMOKE-VIEWER-UA/1.0")
# ⑧ 任务页给普通账号渲染的是只读表,且没有「保存调度配置」按钮
st, tk = page(cli, "/tasks")
chk("⑧ 任务页标注调度只读", "仅管理员可改" in tk)
chk("⑧ 任务页无调度保存按钮", "保存调度配置" not in tk)
# ⑨ 配置页对普通账号只给凭证表单,采集参数渲染成只读表
st, cf = page(cli, "/config")
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
st, sj = page(cli, "/api/status")
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("⑩ is_admin=False", j.get("is_admin") is False, "%s" % j.get("is_admin"))
chk("⑩ can_edit_schedule=False", j.get("can_edit_schedule") is False)
chk("⑩ can_view_logs=False", j.get("can_view_logs") is False)
finally:
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
# `user_id<>0` 是必须的:0 是实例级配置,不能当孤儿清理(见第 3 节的说明)
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# ---------------- 4b. 全局键的落库位置 ----------------
# 这一节盯的是「管理员改了但只有自己生效」这类**静默** bug:
# 全局键若被写进管理员的 user_id,其它账号读取时会回落到 DEFAULTS,
# 表现成「设置莫名其妙不生效」,而且不报任何错。
print("== 4b. 全局键必须落在实例级 user_id=0 ==")
chk("schedule_times 是全局键", config.is_global_key("schedule_times"))
chk("page_size 是全局键", config.is_global_key("page_size"))
chk("cookie 不是全局键(本人凭证)", not config.is_global_key("cookie"))
chk("slot:* 仍是个人级(每人各自记今天跑过没)",
not config.is_global_key("slot:09:00"))
chk("普通账号只被允许写 cookie/user_agent",
config.writable_by("cookie", False) and config.writable_by("user_agent", False)
and not config.writable_by("schedule_times", False)
and not config.writable_by("page_size", False))
with app.test_client() as cli:
login(cli, ADMIN)
same = db.get_setting(conn, "schedule_times", "", 0)
st, _ = page(cli, "/api/settings", method="POST",
json={"schedule_times": same or "09:00"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("管理员写 schedule_times=200", st == 200, "status=%s" % st)
chk("只存在实例级那一份",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id=0"
" AND key='schedule_times'").fetchone()[0] == 1)
chk("管理员名下不留个人级副本(否则别人读不到)",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id<>0"
" AND key='schedule_times'").fetchone()[0] == 0)
# /api/status 是大屏与前端判断角色用的接口,必须真的能开且角色正确
# (它曾经因为改字段时引用了未定义的变量而 500,两层测试都没覆盖到)
st, sj = page(cli, "/api/status")
chk("GET /api/status 管理员=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("└ is_admin=True", j.get("is_admin") is True, "%s" % j.get("is_admin"))
chk("└ can_edit_schedule=True", j.get("can_edit_schedule") is True)
chk("└ 凭证只回「有没有 / 多少字符」",
all(k in j for k in ("cookie_set", "cookie_chars", "cookie_broken"))
and "cookie" not in j)
# 收尾自检:实例级配置必须还在(对照开头那份快照)
inst_after = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("跑完整轮 smoke 后,实例级配置一条不少", inst_before <= inst_after,
"丢失=%s" % sorted(inst_before - inst_after))
conn.close()
# ---------------- 5. 历史缺陷防回归 ----------------
@@ -535,6 +681,99 @@ def run() -> None:
missing = sorted(c for c in used - css_classes - allow)
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
# ---------------- 8. 备份 ----------------
# 这一节盯三件事:
# ① `safe_name` 是下载/恢复接口**唯一**吃文件名的收口点。漏检就是任意文件
# 读取 —— `../../data/instance.json` 能直接把主密钥拿走;
# ② 归档里必须同时有 manifest / 主库 / instance.json。少了 instance.json,
# settings 里的凭证密文就永远解不开了(cookie_key 在里面);
# ③ 备份是管理员专属能力,普通账号连列表都不能看。
# 「破坏 → 恢复 → 比对」的往返**不在这里做**:它要整库替换,不适合对真实库
# 执行,由隔离环境里的专项验证覆盖(见 docs/DEPLOYMENT.md 8.3)。
print("== 8. 备份:路径收口 / 归档完整 / 越权面 ==")
import shutil as _sh
import tempfile as _tf
import zipfile as _zf
from workbuddy_portal import backup
for bad in ("../../etc/passwd", "x.txt", "", "..", "a b.zip", "a.zip/../../x.zip"):
try:
got = backup.safe_name(bad)
# `a.zip/../../x.zip` 这类会被 basename 收敛成合法的 `x.zip`。
# 收敛不算漏检,但结果里**必须**不含任何路径成分。
bad_ok = bool(got) and got == os.path.basename(got) \
and "/" not in got and "\\" not in got and ".." not in got
except backup.BackupError:
bad_ok = True
chk("⑧ safe_name 收口 %r" % bad, bad_ok)
real_dir = config.BACKUP_DIR
tmp_bk = _tf.mkdtemp(prefix="wb-smoke-bk-")
# 第 5 节末尾把 conn 关掉了(那是它自己的收尾动作),这里重新拿一个独立的。
bconn = db.connect()
seen_before = {r["filename"] for r in bconn.execute("SELECT filename FROM backups")}
try:
config.BACKUP_DIR = tmp_bk # 别把真实 backups/ 搅乱
res = backup.create(bconn, "manual", "smoke", "smoke 断言")
ap = backup.path_of(res["filename"])
chk("⑧ 备份生成成功", bool(res["ok"]) and os.path.exists(ap), res["message"])
with _zf.ZipFile(ap) as z:
names = set(z.namelist())
chk("⑧ 归档含 manifest.json", backup.MANIFEST in names)
chk("⑧ 归档含主库快照", os.path.basename(config.SQLITE_PATH) in names)
chk("⑧ 归档含 instance.json(否则 cookie_key 丢失)",
backup.INSTANCE_NAME in names)
v = backup.verify(ap)
chk("⑧ verify 通过", v["integrity"] == "ok",
"uv=%s 条数=%s 账号=%s" % (v["schema_ver"], v["records"], v["users"]))
chk("⑧ verify 报的条数与库一致",
v["records"] == bconn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0],
"records=%s" % v["records"])
backup.create(bconn, "manual", "smoke", "第二份")
backup.sync_index(bconn)
backup.prune(bconn, keep=1, actor="smoke")
left = [f for f in os.listdir(tmp_bk) if f.endswith(backup.SUFFIX)]
chk("⑧ prune keep=1 后只剩 1 份", len(left) == 1, "剩=%d" % len(left))
tmp_user = "smoke_b_%s" % _rand()
bconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
"status,created_at) VALUES(?,?,'备份越权探针',0,'active',?)",
(tmp_user, security.hash_password("Smoke-Pass1"), db.now_str()))
bid = bconn.execute("SELECT id FROM users WHERE username=?", (tmp_user,)).fetchone()["id"]
try:
with app.test_client() as cb:
login(cb, bid)
for p in ("/backups", "/api/backups"):
st, _ = page(cb, p)
chk("⑧ 普通账号 GET %s 被拒" % p, st in (403, 401), "status=%s" % st)
# 导出是 zip,不能走 page()(它按文本解码,会炸在二进制上)
er = cb.get("/profile/export")
body = er.get_data()
try:
with _zf.ZipFile(io.BytesIO(body)) as z:
zn = z.namelist()
except Exception: # noqa: BLE001
zn = []
chk("⑧ 普通账号可导出本人数据(zip)",
er.status_code == 200 and len(zn) >= 4,
"状态=%s 条目=%s" % (er.status_code, zn))
chk("⑧ 导出包里没有 cookie 明文",
not any("cookie" in n.lower() for n in zn))
finally:
bconn.execute("DELETE FROM users WHERE id=?", (bid,))
finally:
config.BACKUP_DIR = real_dir
_sh.rmtree(tmp_bk, ignore_errors=True)
# 归档文件已经随临时目录没了,登记行留着就是脏数据(列表里会显示「已丢失」)
fresh = [r["filename"] for r in bconn.execute("SELECT filename FROM backups")
if r["filename"] not in seen_before]
for n in fresh:
bconn.execute("DELETE FROM backups WHERE filename=?", (n,))
bconn.commit()
bconn.close()
def main() -> int:
print("工程目录:%s\n" % BASE)
+1 -1
查看文件
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
from . import config, db, security
__version__ = "1.2.0"
__version__ = "1.4.0"
PROJECT_NAME = config.PROJECT_NAME
+637
查看文件
@@ -0,0 +1,637 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。
为什么需要它
------------
原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个:
* 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧)
* 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉
* 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路
本模块的三条设计决定
--------------------
1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。
它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是
一个「某一时刻的完整库」。手工 cp 做不到这一点。
2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选
`instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复;
而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开
settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore /
.dockerignore 与 docs/DEPLOYMENT.md)。
3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移
到当前 schema,然后在**一个写事务**里整表搬过去。这样:
* 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件
swap 会因句柄占用直接失败);
* 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完;
* 中途失败就是一个事务回滚,不会留下半个库。
恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。
恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话
立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好
相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。
"""
import hashlib
import json
import logging
import os
import shutil
import sqlite3
import tempfile
import zipfile
from datetime import datetime, timedelta
from . import collect, config, db
log = logging.getLogger("wb.backup")
SUFFIX = ".zip"
MANIFEST = "manifest.json"
INSTANCE_NAME = "instance.json"
FORMAT_VERSION = 1
# 恢复时整表搬运的表清单。
# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」,
# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目
# (而按需重建索引是幂等的,见 sync_index)。
RESTORE_TABLES = ("users", "settings", "usage_records",
"collect_runs", "audit_log", "captchas")
class BackupError(Exception):
"""备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。"""
# ---------------- 路径与文件名 ----------------
def backup_dir():
config.ensure_dirs()
return config.BACKUP_DIR
def safe_name(name):
"""把用户传来的文件名收敛成「备份目录下的一个 zip」。
必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 ——
下载与恢复接口都直接吃文件名,这里是唯一的收口点。
"""
base = os.path.basename(str(name or "").strip().replace("\\", "/"))
if not base or base in (".", "..") or not base.endswith(SUFFIX):
raise BackupError("备份文件名不合法")
if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-"
for c in base):
raise BackupError("备份文件名含非法字符")
return base
def path_of(name):
return os.path.join(backup_dir(), safe_name(name))
def human(n):
n = float(n or 0)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit))
n /= 1024.0
def _app_version():
from . import __version__ # 延迟导入,避开包初始化顺序
return __version__
# ---------------- 元数据 ----------------
def _db_files():
"""数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。"""
out = []
d = config.DATA_DIR
if not os.path.isdir(d):
return out
for fn in sorted(os.listdir(d)):
if not fn.endswith(".sqlite"):
continue
p = os.path.join(d, fn)
if os.path.isfile(p):
out.append(p)
return out
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _snapshot(src_path, dst_path):
"""用在线备份 API 生成一致快照(src 有并发写也安全)。"""
src = sqlite3.connect(src_path, timeout=8.0)
try:
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
def _stats():
"""归档时的关键计数,用于「挑一份恢复」与事后核对。"""
p = config.SQLITE_PATH
if not os.path.exists(p):
return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True)
try:
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv}
finally:
conn.close()
def _extract_safely(zf, dest, names):
"""只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。
`ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外,
而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。
"""
for n in names:
base = os.path.basename(n.replace("\\", "/"))
if not base or base in (".", ".."):
continue
with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst:
shutil.copyfileobj(fsrc, fdst)
# ---------------- 生成备份 ----------------
def create(conn, trigger="manual", actor=None, note=""):
"""打一份新备份,返回结果 dict。失败抛 BackupError。"""
dbs = _db_files()
if not dbs:
raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = "usage-%s%s" % (stamp, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq = 1
while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖
name = "usage-%s-%d%s" % (stamp, seq, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq += 1
st = _stats()
tmpdir = tempfile.mkdtemp(prefix="wb-backup-")
files_meta = []
try:
for i, src in enumerate(dbs):
fn = os.path.basename(src)
# 主库用在线备份 API;其余库(当前没有,但口径要留全)同理
snap = os.path.join(tmpdir, fn)
_snapshot(src, snap)
files_meta.append({"name": fn, "bytes": os.path.getsize(snap),
"sha256": _sha256(snap), "primary": src == config.SQLITE_PATH})
inst = config.INSTANCE_FILE
if os.path.exists(inst):
shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME))
files_meta.append({"name": INSTANCE_NAME,
"bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)),
"sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)),
"secret": True})
manifest = {
"format": FORMAT_VERSION,
"app": config.PROJECT_NAME,
"version": _app_version(),
"created_at": db.now_str(),
"trigger": trigger,
"actor": actor or "",
"note": note or "",
"stats": st,
"files": files_meta,
"restore_note": ("恢复会整表替换 usage_records / settings / users 等;"
"恢复前系统会自动先备份当前库。"),
}
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
for fn in [f["name"] for f in files_meta]:
z.write(os.path.join(tmpdir, fn), fn)
except Exception:
try:
os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」
except OSError:
pass
raise
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
size = os.path.getsize(dest)
digest = _sha256(dest)
db.audit(conn, "backup_create", actor or "system",
"生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]),
"127.0.0.1", 0)
return {"ok": True, "filename": name, "bytes": size, "sha256": digest,
"trigger": trigger, "stats": st,
"message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])}
# ---------------- 索引(表 <- 磁盘) ----------------
def _row_exists(conn, filename):
return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None
def sync_index(conn):
"""把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。
手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存,
**磁盘才是事实来源**,所以这里做双向对齐而不是只信表。
"""
files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)]
for fn in files:
if _row_exists(conn, fn):
continue
p = os.path.join(backup_dir(), fn)
meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
created = ""
# 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。
# 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是
# 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。
trig = "external"
actor = None
note = "从磁盘发现"
try:
info = read_manifest(p)
meta = dict(meta, **info.get("stats", {}))
created = info.get("created_at") or ""
trig = (info.get("trigger") or "").strip() or "external"
actor = (info.get("actor") or "").strip() or None
note = (info.get("note") or "").strip() or note
except BackupError:
pass
if not created:
# 读不出 manifest 就用文件时间,至少让排序有意义
try:
created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S")
except OSError:
created = db.now_str()
conn.execute(
"INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor,"
" schema_ver,records,credits,users,note,missing)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,0)",
(fn, os.path.getsize(p), "", created, trig, actor,
meta.get("schema_ver", 0), meta.get("records", 0),
meta.get("credits", 0.0), meta.get("users", 0), note))
# 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责
for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall():
gone = not os.path.exists(os.path.join(backup_dir(), r["filename"]))
want = 1 if gone else 0
if r["missing"] != want:
conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"]))
return len(files)
def listing(conn):
"""备份清单(新→旧),附磁盘实际大小。"""
out = []
for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"):
p = os.path.join(backup_dir(), r["filename"])
exists = os.path.exists(p)
d = dict(r)
d["exists"] = exists
if exists:
d["bytes"] = os.path.getsize(p)
d["size_h"] = human(d["bytes"])
out.append(d)
return out
def total_bytes(conn):
return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0]
# ---------------- 校验 ----------------
def _name_map(zf):
"""归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。"""
m = {}
for n in zf.namelist():
b = os.path.basename(n.replace("\\", "/"))
if b:
m[b] = n
return m
def read_manifest(path):
try:
with zipfile.ZipFile(path) as z:
entry = _name_map(z).get(MANIFEST)
if entry is None:
raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST)
return json.loads(z.read(entry).decode("utf-8"))
except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e:
raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e))
def verify(path):
"""校验一份归档是否可用于恢复(不修改任何东西)。"""
if not os.path.exists(path):
raise BackupError("备份文件不存在或已被删除")
man = read_manifest(path)
if int(man.get("format") or 0) > FORMAT_VERSION:
raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序"
% (man.get("format"), FORMAT_VERSION))
files = man.get("files") or []
primary = [f for f in files if f.get("primary")] or (files[:1] if files else [])
if not primary:
raise BackupError("归档里没有数据库文件")
tmpdir = tempfile.mkdtemp(prefix="wb-verify-")
try:
with zipfile.ZipFile(path) as z:
nm = _name_map(z)
for f in files:
fn = os.path.basename(str(f.get("name") or ""))
if not fn:
raise BackupError("归档条目名不合法:%s" % f.get("name"))
if fn not in nm:
raise BackupError("归档缺少文件:%s" % f.get("name"))
_extract_safely(z, tmpdir, [nm[fn]])
db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(db_path):
raise BackupError("归档里的主数据库文件解不出来")
conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True)
try:
integ = conn.execute("PRAGMA integrity_check").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
finally:
conn.close()
except sqlite3.Error as e:
raise BackupError("归档里的数据库打不开:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if integ != "ok":
raise BackupError("归档里的数据库完整性检查未通过:%s" % integ)
if uv > db.DB_SCHEMA_VERSION:
raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复"
% (uv, db.DB_SCHEMA_VERSION))
return {"manifest": man, "integrity": integ, "schema_ver": uv,
"records": n, "credits": round(cr or 0, 2), "users": users,
"created_at": man.get("created_at") or "",
"version": man.get("version") or ""}
# ---------------- 恢复 ----------------
def _table_cols(conn, db_name, table):
return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))]
def _copy_tables(conn, src_path):
"""把 src 库里的业务表整表搬到主库,一个事务内完成。
用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库
建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默
错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。
"""
conn.execute("ATTACH DATABASE ? AS src", (src_path,))
try:
plan = []
for t in RESTORE_TABLES:
dst_cols = _table_cols(conn, "main", t)
src_cols = _table_cols(conn, "src", t)
if not src_cols:
continue # 归档里没有这张表(更老的版本)
common = [c for c in dst_cols if c in src_cols]
if not common:
continue
plan.append((t, common))
conn.execute("BEGIN IMMEDIATE")
try:
for t, cols in plan:
cl = ",".join('"%s"' % c for c in cols)
conn.execute("DELETE FROM main.%s" % t)
conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s"
% (t, cl, cl, t))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
return [t for t, _ in plan]
finally:
conn.execute("DETACH DATABASE src")
def _restore_instance(src_file):
"""把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。
必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失,
而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。
"""
dst = config.INSTANCE_FILE
if os.path.exists(dst):
bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S"))
try:
shutil.copy2(dst, bak)
except OSError as e:
raise BackupError("备份 instance.json 失败,已中止:%s" % e)
try:
shutil.copy2(src_file, dst)
if os.name == "posix":
os.chmod(dst, 0o600)
except OSError as e:
raise BackupError("写入 instance.json 失败:%s" % e)
return True
def restore(conn, filename, include_instance=True, actor=None):
"""从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。
副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效**
(恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。
返回结果 dict;失败抛 BackupError。
"""
path = path_of(filename)
info = verify(path) # 先验,验不过就不动任何东西
# 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。
try:
safety = create(conn, trigger="pre-restore", actor=actor or "system",
note="恢复 %s 之前的自动快照" % filename)
except Exception as e: # noqa: BLE001
raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e)
tmpdir = tempfile.mkdtemp(prefix="wb-restore-")
try:
with zipfile.ZipFile(path) as z:
_extract_safely(z, tmpdir, z.namelist())
primary = [f for f in info["manifest"]["files"] if f.get("primary")]
if not primary:
primary = [info["manifest"]["files"][0]]
src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(src_path):
raise BackupError("归档里缺少主数据库文件")
# 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复,
# 而且迁移失败时正本一个字节都没动。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.row_factory = sqlite3.Row
db.init_db(conn=tmp_conn, create_admin=False)
finally:
tmp_conn.close()
# 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。
# 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
tmp_conn.execute("PRAGMA journal_mode=DELETE")
finally:
tmp_conn.close()
# 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。
with collect._Lock():
moved = _copy_tables(conn, src_path)
# 数据整表换过了 —— 让**所有**会话立即失效。
#
# 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在
# 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值,
# 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经
# 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1")
inst = False
if include_instance:
inst_file = os.path.join(tmpdir, INSTANCE_NAME)
if os.path.exists(inst_file):
inst = _restore_instance(inst_file)
out = _stats()
out["moved"] = moved
out["instance"] = inst
except collect.Busy as e:
raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e)
except sqlite3.Error as e:
raise BackupError("恢复过程中数据库报错,已回滚:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
db.audit(conn, "backup_restore", actor or "system",
"从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)"
% (filename, out["records"], out["credits"], out["users"],
",含 instance.json" if out["instance"] else "",
safety.get("filename")),
"127.0.0.1", 0)
return {
"ok": True, "filename": filename, "safety_backup": safety.get("filename"),
"moved": out["moved"], "restored_instance": out["instance"],
"stats": out, "message":
"已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。"
"恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。"
% (filename, out["records"], out["credits"], out["users"],
safety.get("filename")),
}
# ---------------- 删除 / 清理 ----------------
def delete(conn, filename, actor=None):
"""删除一份备份(文件 + 索引行)。"""
name = safe_name(filename)
p = path_of(name)
if not os.path.exists(p):
conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,))
raise BackupError("备份文件不存在(可能已被手工删除)")
os.remove(p)
conn.execute("DELETE FROM backups WHERE filename=?", (name,))
db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0)
return {"ok": True, "filename": name, "message": "已删除备份 " + name}
def prune(conn, keep=None, actor=None):
"""按「保留份数」清理最旧的备份。返回删除清单。"""
if keep is None:
keep = db.get_int(conn, "backup_keep", 7)
keep = max(1, min(100, int(keep or 1)))
rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall()
# 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额
alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))]
victims = alive[keep:]
removed = []
for r in victims:
try:
os.remove(os.path.join(backup_dir(), r["filename"]))
conn.execute("DELETE FROM backups WHERE id=?", (r["id"],))
removed.append(r["filename"])
except OSError as e:
log.warning("清理旧备份 %s 失败:%s", r["filename"], e)
if removed:
db.audit(conn, "backup_prune", actor or "system",
"按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)),
"127.0.0.1", 0)
return removed
# ---------------- 自动备份 ----------------
def last_auto_at(conn):
r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')"
).fetchone()
return (r[0] if r and r[0] else "")
def due(conn, now=None):
"""自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。"""
if not db.get_bool(conn, "backup_enabled", True):
return False, 0.0, 0
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return True, -1.0, every
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return True, -1.0, every
hrs = (now - dt).total_seconds() / 3600.0
return hrs >= every, hrs, every
def maybe_auto(conn, now=None):
"""调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。"""
ok, hrs, every = due(conn, now)
if not ok:
return None
try:
r = create(conn, trigger="auto", actor="system",
note="自动备份(周期 %d 小时)" % every)
except Exception as e: # noqa: BLE001
log.error("自动备份失败:%s", e)
return None
removed = prune(conn, actor="system")
if removed:
log.info("自动备份后清理旧备份 %d 份", len(removed))
log.info("自动备份完成:%s", r["message"])
return r
def next_auto_at(conn, now=None):
"""下一次自动备份时间(给界面显示)。"""
if not db.get_bool(conn, "backup_enabled", True):
return None
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return now
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return now
nxt = dt + timedelta(hours=every)
return nxt if nxt > now else now
+113 -32
查看文件
@@ -18,11 +18,20 @@
字模
----
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移,
笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。
强度说明(为什么要这么多花样)
------------------------------
字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。
在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上
尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。
纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。
反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 ——
验证码是「提高成本」而不是「杜绝」。
"""
import hmac
import os
import math
import random
import secrets
import struct
@@ -140,54 +149,126 @@ class _Canvas:
return bytes(self.buf)
def _brush_line(cv, x0, y0, x1, y1, color, r):
"""用 r×r 方刷画一条线。
旋转后的笔画如果只用点阵格逐个平移,会出现锯齿状断点 —— 一圈一圈的
缝隙正好给「连通域分析」留了把手。这里改成沿线段走样并盖方刷,
笔画连续,旋转也不散架。
"""
steps = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
o = r // 2
for i in range(steps + 1):
t = i / float(steps)
x = int(round(x0 + (x1 - x0) * t))
y = int(round(y0 + (y1 - y0) * t))
cv.rect(x - o, y - o, r, r, color)
def _draw_char(cv, glyph, cx, cy, scale, color, rng):
"""在 (cx, cy) 为中心画一个字符:随机旋转 + 切变 + 波浪 + 粗笔画。
三段变换按「点阵坐标 -> 缩放居中 -> 切变 -> 旋转 -> 波浪纵向偏移」依次施加。
顺序不能乱:先切变再旋转,得到的才是「斜着写的手写体」而不是「被斜切的旋转体」。
"""
ang = rng.uniform(-0.38, 0.38) # 弧度,约 ±22°
cos_a, sin_a = math.cos(ang), math.sin(ang)
shear = rng.uniform(-0.32, 0.32)
amp = rng.uniform(0.0, 2.6) # 波浪振幅(像素)
period = rng.uniform(18.0, 42.0)
phase = rng.uniform(0.0, 6.283)
half_w = GLYPH_W * scale / 2.0
half_h = GLYPH_H * scale / 2.0
r = max(2, scale)
def place(col, row):
px = (col + 0.5) * scale - half_w
py = (row + 0.5) * scale - half_h
px += shear * py
x = cx + px * cos_a - py * sin_a
y = cy + px * sin_a + py * cos_a
return x, y + amp * math.sin(x / period + phase)
for row, bits in enumerate(glyph):
col = 0
while col < len(bits):
if bits[col] != "#":
col += 1
continue
start = col
while col + 1 < len(bits) and bits[col + 1] == "#":
col += 1 # 连续的一段合起来画,笔画才连得上
x0, y0 = place(start, row)
x1, y1 = place(col, row)
_brush_line(cv, x0, y0, x1, y1, color, r)
col += 1
def render(code, width=150, height=56, scale=5, rng=None):
"""把验证码渲染成 PNG 字节串。
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 ——
目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同,
从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。
"""
rng = rng or random.SystemRandom()
n = len(code)
gap = 7
text_w = n * GLYPH_W * scale + (n - 1) * gap
if text_w + 16 > width:
width = text_w + 16
x0 = max(4, (width - text_w) // 2)
y0 = max(3, (height - GLYPH_H * scale) // 2)
gap = 9
# 宽度按最大可能字号算,且左右各留够旋转半径 ——
# 旋转后的字符会往两侧探出约半个字高,留窄了最外侧那个字会被裁掉一截,
# 而「被裁掉一角的字符」会直接变成一次没道理的输错(体验问题,不是安全问题)。
text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap
need_w = text_w + int(GLYPH_H * (scale + 1) * 0.9) + 8
if need_w > width:
width = need_w
# 高度同理:旋转后的字符比原始点阵高不少,切了顶就等于少一个笔画特征
need_h = int(GLYPH_H * (scale + 1) * 1.7) + 8
if need_h > height:
height = need_h
x0 = max(5, (width - text_w) // 2)
y0 = height // 2
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
# 而且打印/截图后对比度更差。
bg = tuple(rng.randint(238, 252) for _ in range(3))
cv = _Canvas(width, height, bg)
# 背景不做纯色:纯色底可以用一个阈值把前景整片切出来。
# 用「两色之间做斜向渐变」能让全局二值化的效果明显变差。
c1 = tuple(rng.randint(236, 252) for _ in range(3))
c2 = tuple(rng.randint(214, 240) for _ in range(3))
slant = rng.uniform(-1.0, 1.0)
cv = _Canvas(width, height, c1)
for y in range(height):
for x in range(width):
t = (x / float(width - 1 or 1)) * 0.6 + (y / float(height - 1 or 1)) * 0.4
t = min(1.0, max(0.0, t + slant * 0.15))
cv.dot(x, y, tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)))
# 1) 干扰线(先画,压在字下面,不遮挡主体)
for _ in range(4):
# 1) 底层干扰线(先画,压在字下面)
for _ in range(3):
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(150, 205) for _ in range(3)))
tuple(rng.randint(170, 215) for _ in range(3)))
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
# 2) 字符本体
step = GLYPH_W * (scale + 1) + gap
for i, ch in enumerate(code):
glyph = _FONT.get(ch)
if glyph is None:
continue
color = tuple(rng.randint(20, 105) for _ in range(3))
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
gy = y0 + rng.randint(-2, 2)
for row, bits in enumerate(glyph):
for col, bit in enumerate(bits):
if bit == "#":
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
# 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了
s = max(3, scale + rng.choice((-1, 0, 0, 1)))
cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0)
cy = y0 + rng.uniform(-3.0, 3.0)
color = tuple(rng.randint(15, 95) for _ in range(3))
_draw_char(cv, glyph, cx, cy, s, color, rng)
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
for _ in range(46):
# 3) 前景噪点:破坏「按连通域找字符」的假设
for _ in range(70):
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(90, 190) for _ in range(3)))
tuple(rng.randint(90, 195) for _ in range(3)))
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
for _ in range(3):
# 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出,
# 所以刻意控制成 2~3 条细线。
for _ in range(rng.randint(2, 3)):
y = rng.randint(2, height - 3)
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
cv.line(0, y, width - 1, y + rng.randint(-11, 11),
tuple(rng.randint(120, 175) for _ in range(3)))
return encode_png(width, height, cv.bytes())
+28
查看文件
@@ -205,6 +205,22 @@ def record_count(conn, uid):
(uid or 0,)).fetchone()[0]
def max_range_days(conn):
"""单次采集允许的最长跨度(天)。
配置值是给管理员的旋钮,**代码层的硬顶**才是兜底:历史脏数据、直接改库、
或者某次误配置都不该让一次请求变成几千次云端调用。
接口校验、页面提示、sync() 里的实际收窄都读这一个函数,避免三处口径漂移。
"""
n = db.get_int(conn, "collect_max_range_days", config.COLLECT_MAX_RANGE_DAYS_HARD)
return max(1, min(config.COLLECT_MAX_RANGE_DAYS_HARD, n))
def min_interval_seconds(conn):
"""同一账号两次手动采集之间的最小间隔(秒)。"""
return max(0, min(3600, db.get_int(conn, "collect_min_interval_seconds", 60)))
def last_ts(conn, uid):
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0]
@@ -285,6 +301,18 @@ def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None
end = to_dt or now
if start >= end:
start = end - timedelta(minutes=rewind)
# 跨度上限(**代码层兜底**,不只是接口校验)。
# 采集一次 = 对云端发 ceil(条数/page_size) 次请求,跨度越长请求越多。
# 不设顶时,一个注册账号用 from=2000-01-01 就能让服务端替它打几千次云端,
# 同时独占全局采集锁与一个 waitress 线程 —— 最省力的资源耗尽方式。
# 这里对「显式跨度」和「断点很旧导致的实际跨度」一视同仁地收窄。
max_days = max_range_days(conn)
if end - start > timedelta(days=max_days):
original = start
start = end - timedelta(days=max_days)
_log("[warn] 请求跨度超过上限 %d 天,已自动收窄起点:%s -> %s"
% (max_days, original.strftime("%Y-%m-%d %H:%M:%S"),
start.strftime("%Y-%m-%d %H:%M:%S")))
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
end.strftime("%Y-%m-%d %H:%M:%S")))
+165 -19
查看文件
@@ -28,8 +28,18 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
APP_LOG = os.path.join(LOG_DIR, "app.log")
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)
# 备份落点。**刻意与 DATA_DIR 分开**:
# * 容器里 DATA_DIR 挂的是数据卷,`docker compose down -v` 会连卷一起删;
# 备份若躺在同一个卷里,就等于「正本与副本同时消失」—— 备份的意义没了。
# * 备份里含 settings 的凭证密文与 users 的口令散列,必须能单独控制权限、
# 单独挂卷、单独排除出镜像(见 .dockerignore 的 backups/)。
BACKUP_DIR = os.environ.get("WB_BACKUP_DIR") or os.path.join(BASE_DIR, "backups")
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
# 后面两个保留以兼容「还没挪走」的部署。
LEGACY_CSV_CANDIDATES = [
os.path.join(os.path.dirname(BASE_DIR), "legacy-v1", "data", "usage_records.csv"),
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
os.path.join(BASE_DIR, "data", "usage_records.csv"),
]
@@ -50,7 +60,19 @@ DEFAULTS = {
"register_max_per_ip": "3", # 同一 IP 每天最多注册几个账号
"captcha_policy": "always", # always | adaptive | off(见 CAPTCHA_POLICIES)
"captcha_length": "4", # 验证码字符数 4~6
# ---- 个人级:采集参数 ----
# ---- 实例级:采集调度(全实例统一,见 GLOBAL_KEYS)----
"schedule_enabled": "1",
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
"max_schedule_slots_per_day": "6", # 每天最多几个时刻(挡住「填 200 个时刻」)
"collect_min_interval_seconds": "60", # 同一账号两次手动采集的最小间隔
"collect_max_range_days": "31", # 单次采集的最长跨度(硬顶 31 天 = 1 个月)
# ---- 实例级:自动备份 ----
"backup_enabled": "1", # 是否开启自动备份
"backup_interval_hours": "24", # 备份周期(小时)
"backup_keep": "7", # 保留最近几份,超出的自动删除最旧的
# ---- 实例级:采集参数 ----
"page_size": "200",
"rewind_minutes": "2", # 断点回退分钟数
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
@@ -58,25 +80,61 @@ DEFAULTS = {
"verify_days": "0", # 每次采集后做整日完整性校验的天数
"timeout": "30",
"ssl_verify": "1", # 校验云端 HTTPS 证书(cookie 是凭证,不该裸奔)
# ---- 个人级:调度 ----
"schedule_enabled": "1",
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
# ---- 个人级:凭证(每个账号自己的,Cookie 静态加密后入库)----
"cookie": "",
"user_agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"),
}
# 实例级配置:所有账号共用一份,只有管理员能改。
# 其余键(采集参数 / 调度 / 凭证)都是个人级 —— 这正是「多用户」的核心:
# 每个人填自己的 Cookie、收自己的数据、定自己的采集时刻。
# ---------------- 配置的作用域与写权限(改这里之前先读完整段)----------------
#
# 三级回落:**个人级(user_id=n) -> 实例级(user_id=0) -> config.DEFAULTS**。
# 但「谁能写哪个键」和「键存在哪一级」是两件事,本项目把两者对齐成一条规则:
#
# 普通用户只能写 USER_EDITABLE_KEYS(本人凭证);
# 其余所有键都归管理员,且一律存在**实例级**(user_id=0)。
#
# 为什么采集参数 / 调度也要放到实例级,而不是「个人级但只有管理员能写」:
# 如果它们只在管理员自己的 user_id 下,其它账号读取时会回落到
# DEFAULTS,管理员改的值对别人**完全不生效** —— 那才是真正的坑。
# 统一放实例级,语义是「一台部署一套采集与调度策略」,读起来也简单。
GLOBAL_KEYS = {
# 云端接口
"api_base", "api_path",
# 开放注册与防攻击策略
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
"max_schedule_slots_per_day",
# 任务频率与采集跨度(v1.4.0 起:对外提供服务时必须能限流,
# 否则一个注册账号就能拿 /api/collect 把云端与线程池打满)
"collect_min_interval_seconds", "collect_max_range_days",
# 自动备份(谁掌握备份谁就掌握全库数据,所以归管理员)
"backup_enabled", "backup_interval_hours", "backup_keep",
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
"verify_days", "timeout", "ssl_verify",
}
# 普通用户**唯一**可写的两个键:本人账号的云端凭证。
# cookie —— 静态加密后入库,页面/接口只回掩码
# user_agent —— 必须与拿 Cookie 的那次请求同源,所以和 Cookie 归在一起
# 这两个键是个人级、且不参与实例级回落(见 db.NO_FALLBACK_KEYS):
# 回落等于「用别人的身份采集」,是最严重的一类越权。
USER_EDITABLE_KEYS = {"cookie", "user_agent"}
def is_user_key(key):
"""是否属于「个人凭证」类配置。"""
return key in USER_EDITABLE_KEYS
def writable_by(key, is_admin):
"""当前角色能否写这个键 —— 前后端与测试都走这一个判断,避免两处规则漂移。"""
if key in USER_EDITABLE_KEYS:
return True
return bool(is_admin)
# 验证码策略
CAPTCHA_POLICIES = {
"always": "始终要求(默认,最安全)",
@@ -117,18 +175,33 @@ NUM_SETTINGS = {
"catch_up_grace_hours": (1, 168, "小时"),
"captcha_length": (4, 6, "个字符"),
"register_max_per_ip": (1, 50, "个/天"),
"max_schedule_slots_per_day": (1, 12, "个/天"),
"collect_min_interval_seconds": (0, 3600, "秒"),
"collect_max_range_days": (1, 31, "天"),
"backup_interval_hours": (1, 720, "小时"),
"backup_keep": (1, 100, "份"),
}
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"}
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register", "backup_enabled"}
# 采集跨度的**硬顶**:无论 settings 里被改成什么(含历史脏数据、手工改库),
# 代码层一律按这个上限夹一次。写进配置只是给管理员一个更严的旋钮,
# 不是「改大就能突破」——上限必须由代码兜底,不能只靠校验。
COLLECT_MAX_RANGE_DAYS_HARD = 31
# 每日调度时刻的硬顶(同上)
SCHEDULE_SLOTS_HARD_MAX = 12
_TRUE = ("1", "true", "yes", "on", "是", "启用")
def normalize_setting(key, raw):
def normalize_setting(key, raw, conn=None):
"""校验并规范化单个设置值。
返回 (value, error):
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
* `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受
`max_schedule_slots_per_day` 约束),有连接时才查得到。
不传时只做代码层的硬顶校验,所以离线调用不会因此失败。
"""
if key not in DEFAULTS:
return None, "未知配置项:%s" % key
@@ -158,6 +231,19 @@ def normalize_setting(key, raw):
parsed = scheduler.parse_times(raw)
if not parsed:
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
if len(parsed) > SCHEDULE_SLOTS_HARD_MAX:
return None, "每日时刻最多 %d 个(当前填了 %d 个)" % (
SCHEDULE_SLOTS_HARD_MAX, len(parsed))
# 可配置的更严上限:时刻数量直接决定调度器的采集频次,
# 是「一个账号能不能把云端与线程池打满」的开关,所以要有刹车。
if conn is not None:
from . import db as _db
cap = _db.get_int(conn, "max_schedule_slots_per_day", 6)
cap = max(1, min(SCHEDULE_SLOTS_HARD_MAX, cap))
if len(parsed) > cap:
return None, ("每日时刻最多 %d 个(当前填了 %d 个)。"
"如需更多,请先把「每日调度时刻上限」调大。"
% (cap, len(parsed)))
return ",".join(parsed), None
if key == "captcha_policy":
@@ -170,8 +256,14 @@ def normalize_setting(key, raw):
v = str(raw).strip()
if not v:
return None, "%s 不能为空" % key
if key == "api_base" and not v.startswith(("http://", "https://")):
if key == "api_base":
if not v.startswith(("http://", "https://")):
return None, "接口基址需以 http:// 或 https:// 开头"
# 云元数据地址永远不该是「云端接口」:它是 SSRF 拿云上临时凭证
# 最经典的一跳,而且没有任何合法的采集场景需要它。
host = v.split("//", 1)[1].split("/", 1)[0].split(":")[0].lower()
if host in ("169.254.169.254", "metadata.google.internal", "[fd00:ec2::254]"):
return None, "接口基址不能指向云元数据地址"
return v, None
if key == "cookie":
@@ -184,8 +276,57 @@ def normalize_setting(key, raw):
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
DEFAULT_PORT = 8848
SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数
LOGIN_LOCK_MINUTES = 10
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁)
LOGIN_LOCK_MINUTES = 10 # 硬锁时长(仅 IP 维度)
# 用户名维度的**软退避**:对外提供服务后,「知道一个用户名就能把它锁死 10 分钟」
# 本身就是一种攻击(拿管理员用户名当武器,别人也用不了)。所以用户名维度
# 只产生秒级、递增、有封顶的等待,真正的重锁只按来源 IP 施加。
USER_SOFT_THRESHOLD = 5 # 同一用户名失败超过这个次数才开始退避
USER_SOFT_CAP_SECONDS = 60 # 退避封顶
# 同一来源的登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」的形态
LOGIN_ATTEMPTS_PER_IP = 40
LOGIN_ATTEMPTS_WINDOW = 300 # 秒
# ---------------- 反向代理与传输安全 ----------------
def _env_flag(name, default="0"):
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
# 是否信任 X-Forwarded-For。**默认不信任**。
# 直接暴露给公网(或前面只有一个「追加型」代理)时,XFF 的第 0 段是攻击者
# 自己填的:一旦采信,验证码限速、注册配额、登录锁定三道 IP 防线会同时失效
# (实测:每次换一个伪造 XFF,45 次验证码请求全部放行)。
# 只有在**你自己的**反向代理会重写该头(nginx: `$remote_addr`)时才置 1。
TRUST_PROXY = _env_flag("WB_TRUST_PROXY")
# 强制跳转 HTTPS(配合反代时用;读到 X-Forwarded-Proto: https 就不跳)
FORCE_HTTPS = _env_flag("WB_FORCE_HTTPS")
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
COOKIE_SECURE = _env_flag("WB_COOKIE_SECURE")
# 访问日志:waitress 自己不记 access log,上线后没有访问日志等于出事无据可查。
# 只记非静态资源请求,写进 logs/app.log(滚动 2MB × 3)。
ACCESS_LOG = _env_flag("WB_ACCESS_LOG", "1")
# waitress 线程数。与容器 CPU 上限配套:线程越多,单实例能同时吃进的
# 慢请求(如采集、导出)就越多 —— 对外提供服务时这是资源上限的一部分。
THREADS = int(os.environ.get("WB_THREADS") or 8)
# 首个管理员的初始口令。**不给默认值**:留空时 db.init_db 会生成一个随机口令
# 并只在启动日志里打印一次 —— 硬编码一个 admin123 等于把公网实例的钥匙挂在门上。
ADMIN_USER = os.environ.get("WB_ADMIN_USER") or "admin"
ADMIN_PASSWORD = os.environ.get("WB_ADMIN_PASSWORD") or ""
# 口令黑名单:这些是自动撞库字典的头几页,命中即拒。
# 只在「设置/修改口令」时校验(登录不校验),所以不会把用老口令的人挡在门外。
WEAK_PASSWORDS = {
"12345678", "123456789", "1234567890", "password", "password1", "password123",
"passw0rd", "qwertyui", "qwerty123", "abc12345", "abcd1234", "admin123",
"admin888", "admin1234", "administrator", "root1234", "letmein1", "welcome1",
"iloveyou", "monkey123", "dragon123", "sunshine", "princess", "football",
"baseball", "11111111", "00000000", "88888888", "66666666", "asdasd123",
"1qaz2wsx", "zxcvbnm1", "a1234567", "workbuddy", "codebuddy",
}
# ---------------- 账号与口令策略 ----------------
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
@@ -195,13 +336,13 @@ PASSWORD_MAX = 128
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
PROFILE_EMAIL_MAX = 128
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on")
# 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段,
# 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定,
# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
def ensure_dirs():
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR):
os.makedirs(d, exist_ok=True)
@@ -227,6 +368,11 @@ def _instance_init(key, maker):
try:
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 这个文件里躺着 SECRET_KEY 与 cookie_key,权限等同管理员口令。
# 默认 umask 022 会留下 0644(同机其他用户可读),所以在 POSIX 上
# 显式收紧到 0600。Windows 没有这个概念,忽略即可。
if os.name == "posix":
os.chmod(INSTANCE_FILE, 0o600)
except OSError:
pass # 只读文件系统时退化为「本次进程内有效」
return val
+134 -8
查看文件
@@ -14,19 +14,29 @@
多用户约定(改代码前务必先读):
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
的代码都不可能把凭证带出去。
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
虽然时刻本身是实例级的 —— 这两个千万别一起改。
"""
import logging
import os
import secrets
import sqlite3
import threading
from datetime import datetime
from . import config, crypto
log = logging.getLogger("wb.db")
_local = threading.local()
_init_lock = threading.Lock()
_initialized = False
@@ -34,11 +44,23 @@ _initialized = False
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
DB_SCHEMA_VERSION = 2
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
DB_SCHEMA_VERSION = 4
# 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里,
# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。
# `manage.py init` / entrypoint 负责把它打印到启动日志。
_generated_admin_password = None
def generated_admin_password():
"""本次进程启动时生成的管理员口令(没有生成过则为 None)。"""
return _generated_admin_password
class SecretUnreadable(Exception):
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
@@ -160,7 +182,8 @@ def _migrate(conn):
for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"),
("last_login_ip", "TEXT")):
("last_login_ip", "TEXT"),
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col)
@@ -225,6 +248,70 @@ def _encrypt_legacy_secrets(conn):
return done
def _promote_personal_to_global(conn):
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
必须做两件事,否则会静默丢配置:
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
跳过),但会让后面排障的人以为「这个键是个人级的」。
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
"""
owner = _first_owner_uid(conn)
promoted, cleaned = [], 0
for k in sorted(config.GLOBAL_KEYS):
if owner:
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(owner, k)).fetchone()
if mine is not None and mine["value"] is not None:
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(k,)).fetchone()
if cur is None or cur["value"] != mine["value"]:
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
promoted.append(k)
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
cleaned += cur2.rowcount or 0
return promoted, cleaned
def _create_first_admin(conn, admin_user, admin_password, ts):
"""建库后的第一个管理员。
口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。
这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令
等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。
生成的口令只在启动日志里打印一次,并被提示立即修改。
"""
global _generated_admin_password
from .security import hash_password
pwd = (admin_password or config.ADMIN_PASSWORD or "").strip()
generated = False
if not pwd:
pwd = secrets.token_urlsafe(12)
generated = True
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
if generated:
_generated_admin_password = pwd
# 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。
log.warning("=" * 68)
log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user)
log.warning(" 用户名:%s", admin_user)
log.warning(" 口 令:%s", pwd)
log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。")
log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。")
log.warning("=" * 68)
return generated
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized
@@ -244,9 +331,18 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
ts = now_str()
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
#
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
for k, v in config.DEFAULTS.items():
if k in config.USER_EDITABLE_KEYS:
continue
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
" VALUES(0,?,?,?)", (k, v, ts))
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
for k in sorted(config.USER_EDITABLE_KEYS):
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
# 顺手把历史明文凭证加密(幂等;新库无事可做)
enc = _encrypt_legacy_secrets(conn)
if enc:
@@ -255,15 +351,20 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
" VALUES(0,?,?,?,?,?)",
(ts, "system", "encrypt_secrets",
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
promoted, cleaned = _promote_personal_to_global(conn)
if promoted or cleaned:
note = "配置作用域收敛:提升 %s 到实例级%s" % (
", ".join(promoted) if promoted else "(无)",
",清理 %d 条个人级残留" % cleaned if cleaned else "")
migrated.append(note)
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
from .security import hash_password
pwd = admin_password or "admin123"
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
_create_first_admin(conn, admin_user, admin_password, ts)
_initialized = True
return migrated
finally:
@@ -400,6 +501,31 @@ def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
def session_ver_of(row):
"""安全读取 session_ver。
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
那会把「一次可恢复的登录失效」变成「整站 500」。
"""
try:
if "session_ver" not in row.keys():
return 0
except AttributeError: # 不是 Row(dict 等)
return 0
return int(row["session_ver"] or 0)
def bump_session_ver(conn, uid):
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
旧会话依然有效到 12 小时之后。
"""
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
(int(uid or 0),))
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
+11 -1
查看文件
@@ -26,7 +26,7 @@ import os
import threading
from datetime import datetime, timedelta
from . import collect, db
from . import backup, collect, db
log = logging.getLogger("wb.scheduler")
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
@@ -176,6 +176,16 @@ class Scheduler:
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
except Exception as e:
log.error("账号 %s 采集失败:%s", u["username"], e)
# ---- 自动备份(实例级,与具体账号无关,所以放在账号循环之外)----
# 有采集在跑就跳过,等下一轮:备份会整库读一遍,没必要和采集抢磁盘。
try:
if os.path.exists(collect.LOCK_PATH):
log.debug("有采集在跑,本次跳过自动备份")
else:
backup.maybe_auto(conn, now)
except Exception as e: # 备份失败不能拖累调度本身
log.exception("自动备份出错:%s", e)
return True
+23
查看文件
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效
created_at TEXT,
register_ip TEXT, -- 自助注册来源,用于每日限额
last_login_at TEXT,
@@ -119,3 +120,25 @@ CREATE TABLE IF NOT EXISTS captchas (
);
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
-- ---------------- 备份索引 ----------------
-- 表里只放「元数据」,备份文件本身在 config.BACKUP_DIR(**不在** data/ 卷里,
-- 免得 `docker compose down -v` 把正本和备份一起删掉)。
-- 刻意不记录备份内容、也不记录任何凭证 —— 这里只是一份可下载清单。
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE, -- 备份目录内的文件名(不含路径)
bytes INTEGER NOT NULL DEFAULT 0, -- 归档大小
sha256 TEXT NOT NULL DEFAULT '', -- 归档整体校验值(前 64 位十六进制)
created_at TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'manual', -- manual | auto | cli | pre-restore
actor TEXT, -- 触发者用户名(自动备份为 system)
schema_ver INTEGER NOT NULL DEFAULT 0, -- 归档时的 user_version
records INTEGER NOT NULL DEFAULT 0, -- 归档时的记录条数(便于挑一份恢复)
credits REAL NOT NULL DEFAULT 0,
users INTEGER NOT NULL DEFAULT 0,
note TEXT, -- 备注 / 恢复来源
missing INTEGER NOT NULL DEFAULT 0 -- 1 = 文件已不在磁盘上(手工删过)
);
CREATE INDEX IF NOT EXISTS idx_backups_at ON backups(created_at DESC);
+216 -9
查看文件
@@ -10,14 +10,26 @@
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
users.status 与 users.session_ver,不靠会话过期来兜底。
3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。
来源 IP 的取法(对外提供服务时最容易出错的一处)
------------------------------------------------
`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`:
反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是
攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道
IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头)
时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的
一跳,由我们自己的代理写入,客户端伪造不了。
"""
import functools
import hmac
import ipaddress
import logging
import re
import secrets
import sqlite3
import time
from flask import (current_app, flash, g, jsonify, redirect, render_template,
@@ -25,10 +37,50 @@ from flask import (current_app, flash, g, jsonify, redirect, render_template,
from . import captcha, config, db
log = logging.getLogger("wb.security")
# ---------------- 来源 IP(全站唯一入口) ----------------
def _valid_ip(s):
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def client_ip():
"""当前请求的客户端地址。
* `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。
直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。
* 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。
最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。
多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠
代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。
"""
remote = (request.remote_addr or "").strip()
if not config.TRUST_PROXY:
return remote
raw = request.headers.get("X-Forwarded-For", "")
if not raw:
return remote
for part in reversed([p.strip() for p in raw.split(",")]):
# 去掉 IPv6 的 [..]:port 写法
cand = part.strip("[]").split("%")[0]
if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit():
cand = cand.rsplit(":", 1)[0] # IPv4:port
if _valid_ip(cand):
return cand
# 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用
log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120])
return remote
# ---------------- 失败计数(内存即可) ----------------
# 单进程部署(见 README 的部署约束),重启清零可接受;
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
_fails = {} # key -> [count, last_ts]
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
@@ -88,9 +140,76 @@ def fail_count(key):
return c[0] if c else 0
# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)----
def ip_lock_left(ip):
c = _fails.get(_ip_key(ip))
if not c or c[0] < config.MAX_LOGIN_FAILS:
return 0
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
# ---- 用户名维度:只做秒级退避,**不做长锁** ----
def user_soft_left(username):
"""知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。
对外提供服务后,管理员用户名是公开信息(导航里就写着),
如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员
十分钟进不去。所以这里改成「递增且有封顶」的秒级等待:
超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。
真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、
也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数,
所以这种「软」不会让爆破变得可行。
"""
c = _fails.get(_user_key(username))
if not c or c[0] < config.USER_SOFT_THRESHOLD:
return 0
delay = min(config.USER_SOFT_CAP_SECONDS,
1 << min(10, c[0] - config.USER_SOFT_THRESHOLD))
return max(0, int(delay - (time.time() - c[1])))
# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」----
def note_try(ip):
now = time.time()
if len(_tries) > _FAILS_MAX_KEYS:
_tries.clear()
cur = _tries.get(ip)
if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW:
_tries[ip] = [1, now]
return 1
cur[0] += 1
return cur[0]
def try_window_left(ip):
cur = _tries.get(ip)
if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP:
return 0
return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1])))
def auth_locked(ip, username=""):
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
"""还需等待的秒数(0 = 放行)。"""
return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username))
def auth_block_reason(ip, username=""):
"""被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。"""
if ip_lock_left(ip):
return "ip"
if try_window_left(ip):
return "rate"
if user_soft_left(username):
return "user"
return ""
def auth_block_message(reason, seconds):
if reason == "ip":
return "该来源登录失败次数过多,请 %d 秒后再试" % seconds
if reason == "rate":
return "登录请求过于频繁,请 %d 秒后再试" % seconds
return "尝试过于频繁,请 %d 秒后再试" % seconds
def note_auth_fail(ip, username=""):
@@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""):
def clear_auth_fail(ip, username=""):
"""登录成功后清掉两个维度的计数。
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
之后,主人仍会被自己之前那几次的退避拖住。
"""
clear_fail(_ip_key(ip))
if username:
clear_fail(_user_key(username))
# ---------------- 通用动作限速(重操作保护) ----------------
# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求:
# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程;
# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车,
# 否则**一个注册账号**就能把实例的线程与带宽吃干净。
_actions = {} # "name:uid" -> next_allowed_ts
def action_allowed(key, min_interval):
"""返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。"""
now = time.time()
if len(_actions) > _FAILS_MAX_KEYS:
_actions.clear()
nxt = _actions.get(key) or 0
if now < nxt:
return False, int(nxt - now) + 1
_actions[key] = now + max(0, int(min_interval))
return True, 0
def action_wait_left(key):
return max(0, int((_actions.get(key) or 0) - time.time()))
# ---------------- 口令 / 用户名策略 ----------------
_USERNAME_RE = re.compile(config.USERNAME_RE)
@@ -149,6 +297,10 @@ def password_problem(new, new2=None, username=None):
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
if classes < 2:
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
if new.lower() in config.WEAK_PASSWORDS:
return "这个密码在常见弱口令字典里,请换一个"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
if username and new.lower() == str(username).lower():
@@ -182,8 +334,12 @@ def login_ok(conn, username, password):
def current_user():
"""当前登录用户(dict)或 None。
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
每个请求回查一次 users 表,两道校验:
* `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
旧会话照样有效到 12 小时之后。
结果缓存在 flask.g 里,一次请求只查一次。
"""
if "wb_user" in g:
return g.wb_user
@@ -192,12 +348,24 @@ def current_user():
if uid:
try:
row = db.get_db().execute(
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
(uid,)).fetchone()
"SELECT id,username,display_name,is_admin,status,session_ver"
" FROM users WHERE id=?", (uid,)).fetchone()
except sqlite3.OperationalError as e:
# users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db
# 就先把 Web 起起来了,老库还没有 session_ver 这一列)。
# 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉,
# 症状会变成「全站所有人被踢下线、日志里什么都没有」——
# 界面上只看到「登录成功又立刻跳回登录页」,极难归因。
log.error("会话校验失败:users 表结构与代码不一致(%s);"
"请执行 `python manage.py init` 完成迁移", e)
row = None
except Exception: # noqa: BLE001 (无请求上下文等)
row = None
if row is None or (row["status"] or "active") != "active":
session.clear()
elif session.get("sv", 0) != db.session_ver_of(row):
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
session.clear()
else:
user = {"id": row["id"], "username": row["username"],
"display_name": row["display_name"] or row["username"],
@@ -219,12 +387,16 @@ def login_session(user):
`session.clear()` 是必须的:既清掉前一次的残留,
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
这一版会话会在下一个请求就被判为过期。
"""
session.clear()
session["uid"] = user["id"]
session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"]
session["adm"] = 1 if user["is_admin"] else 0
session["sv"] = db.session_ver_of(user)
session["login_at"] = db.now_str()
session.permanent = True
@@ -401,19 +573,54 @@ def apply_security_headers(resp):
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
if config.COOKIE_SECURE or config.FORCE_HTTPS:
resp.headers.setdefault("Strict-Transport-Security",
"max-age=31536000; includeSubDomains")
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
resp.headers.setdefault("Cache-Control", "no-store")
return resp
def needs_https_redirect():
"""当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。"""
if not config.FORCE_HTTPS or request.is_secure:
return False
# 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。
# 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。
if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
return False
if request.method not in ("GET", "HEAD"):
return False # 不重定向 POST:会丢请求体,行为难以预期
return True
def _access_log(resp, started):
if not config.ACCESS_LOG:
return resp
path = request.path
if path.startswith("/static/") or path == "/captcha.png":
return resp # 静态资源与验证码出图会把日志刷满
log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code,
int((time.time() - started) * 1000), client_ip())
return resp
def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user
@app.before_request
def _guard():
g.wb_t0 = time.time()
if needs_https_redirect():
url = request.url.replace("http://", "https://", 1)
return redirect(url, code=301)
return check_csrf()
@app.after_request
def _headers(resp):
_access_log(resp, getattr(g, "wb_t0", time.time()))
return apply_security_headers(resp)
+262 -27
查看文件
@@ -13,15 +13,19 @@
**多用户约定**
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
所以这里漏传会直接 TypeError(而不是静默返回全量)。
`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示,
但**拒绝**非管理员写入实例级键。
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
"""
import os
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, send_file, session
from .. import collect, config, db, query, scheduler, security
from .. import backup, collect, config, db, query, scheduler, security
from ..security import admin_required, current_user, is_admin, login_required
bp = Blueprint("api", __name__, url_prefix="/api")
@@ -32,6 +36,12 @@ def _uid():
return u["id"] if u else 0
def _ip():
"""客户端地址。一律走 security.client_ip() —— 见那里的注释:
直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。"""
return security.client_ip()
def _arg(name, default=None):
v = request.args.get(name)
return v if v not in (None, "") else default
@@ -76,7 +86,13 @@ def _int(name, default, lo=1, hi=2000):
@bp.get("/manifest")
@login_required
def api_manifest():
return jsonify(query.manifest(db.get_db(), _uid()))
u = current_user()
m = query.manifest(db.get_db(), u["id"])
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
m["role"] = "admin" if u["is_admin"] else "user"
return jsonify(m)
@bp.get("/bundle")
@@ -174,7 +190,8 @@ def api_run(run_id):
@login_required
def api_status():
conn = db.get_db()
uid = _uid()
u = current_user()
uid = u["id"]
sch = scheduler.get_scheduler()
nxt = scheduler.next_run_at(conn, uid)
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
@@ -182,8 +199,26 @@ def api_status():
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
(uid,)).fetchone()[0]
cred = db.secret_state(conn, "cookie", uid)
nxt_bk = backup.next_auto_at(conn) if u["is_admin"] else None
return jsonify({
"server_time": db.now_str(),
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
# 决定要不要渲染管理员专属入口(如「日志管理」「备份管理」)。服务端仍会
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
"is_admin": bool(u["is_admin"]),
"can_edit_schedule": bool(u["is_admin"]),
"can_view_logs": bool(u["is_admin"]),
"can_manage_backups": bool(u["is_admin"]),
"limits": {
# 对外提供服务时的三道闸门,前端据此提前禁用按钮而不是等 409
"collect_min_interval_seconds": collect.min_interval_seconds(conn),
"collect_max_range_days": collect.max_range_days(conn),
"schedule_slots": len(scheduler.slots(conn, uid)),
"schedule_slots_cap":
max(1, min(config.SCHEDULE_SLOTS_HARD_MAX,
db.get_int(conn, "max_schedule_slots_per_day", 6))),
},
"running_lock": os.path.exists(collect.LOCK_PATH),
"scheduler": {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
@@ -192,6 +227,13 @@ def api_status():
"catch_up": db.get_bool(conn, "catch_up", True, uid),
"lockfile": os.path.exists(collect.LOCK_PATH),
},
"backup": {
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last": backup.last_auto_at(conn) or None,
"next": nxt_bk.strftime("%Y-%m-%d %H:%M:%S") if nxt_bk else None,
},
"running_runs": running,
"last_run": dict(last) if last else None,
# 只回「有没有配」与字符数,绝不回凭证内容
@@ -204,10 +246,40 @@ def api_status():
@bp.post("/collect")
@login_required
def api_collect():
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
"""手动触发一次采集(同步执行,页面等待结果)。
对外提供服务后,这里必须有三道闸门,缺一不可:
1. **已有任务在跑就拒绝新任务**。采集是全局单写者(SQLite 同一时刻只允许
一个写进程,见 collect._Lock)。原来的实现是「后来者在锁上等」,
而 waitress 只有 8 个线程 —— 一个人连点几下就能把线程占满,
表现为整个站点变慢甚至无响应。现在明确回 409。
2. **同一账号的最小间隔**。挡住「连点按钮 / 脚本循环调」这种形态;
间隔由 `collect_min_interval_seconds` 控制(实例级,管理员可调)。
3. **跨度上限**。`from=2000-01-01` 会让服务端对云端发出成百上千次请求,
这是最省力的资源耗尽方式。上限 `collect_max_range_days`(硬顶 31 天)。
"""
u = current_user()
conn = db.get_db()
uid = u["id"]
body = _json_body()
# ---- 闸门 1:不能有别的任务在跑 ----
if os.path.exists(collect.LOCK_PATH):
return jsonify({"ok": False, "error": "busy",
"message": "已有采集任务正在运行,请等它结束后再试"
"(进度见「任务管理」)"}), 409
# ---- 闸门 2:频率 ----
gap = collect.min_interval_seconds(conn)
allowed, wait = security.action_allowed("collect:%d" % uid, gap)
if not allowed:
return jsonify({"ok": False, "error": "too_frequent",
"message": "同一账号两次采集之间需间隔 %d 秒,请 %d 秒后再试"
% (gap, wait)}), 429
frm, to = body.get("from"), body.get("to")
limit = collect.max_range_days(conn)
try:
kw = {}
if frm:
@@ -222,7 +294,16 @@ def api_collect():
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
raise BadParam("起始日期不能晚于结束日期")
r = collect.run_sync(trigger="manual", uid=u["id"], **kw)
# ---- 闸门 3:跨度 ----
if kw.get("from_dt"):
end = kw.get("to_dt") or datetime.now()
days = (end - kw["from_dt"]).days
if days > limit:
raise BadParam(
"采集跨度最长 %d 天,本次请求是 %d 天。"
"请缩小日期范围后分批采集(每次最多 %d 天)。"
% (limit, days, limit))
r = collect.run_sync(trigger="manual", uid=uid, **kw)
except BadParam as e:
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
except collect.Busy as e:
@@ -239,7 +320,7 @@ def api_collect():
"message": str(e)}), code
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"])
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
return jsonify({"ok": True, "result": r})
@@ -290,6 +371,14 @@ def api_maintenance(action):
if action == "vacuum" and not u["is_admin"]:
return jsonify({"ok": False, "error": "forbidden",
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
# 这四类动作都是「整库扫一遍」级别:导出会全表流式扫、补全会连续打云端、
# vacuum 会锁库。各自加一个按账号的最小间隔,挡住脚本循环调用。
hvy = {"fill-prompt": 60, "export-csv": 15, "vacuum": 120, "recount": 5}
if action in hvy:
ok, wait = security.action_allowed("maint:%s:%d" % (action, uid), hvy[action])
if not ok:
return jsonify({"ok": False, "error": "too_frequent",
"message": "该动作刚执行过,请 %d 秒后再试" % wait}), 429
try:
if action == "fill-prompt":
try:
@@ -319,10 +408,118 @@ def api_maintenance(action):
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid)
db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid)
return jsonify({"ok": True, "message": msg})
# ---------------- 备份管理(仅管理员) ----------------
def _backup_error(e):
return jsonify({"ok": False, "error": "backup", "message": str(e)}), 400
@bp.get("/backups")
@admin_required
def api_backups():
conn = db.get_db()
backup.sync_index(conn)
return jsonify({
"items": backup.listing(conn),
"dir": config.BACKUP_DIR,
"total_human": backup.human(backup.total_bytes(conn)),
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last_auto": backup.last_auto_at(conn),
})
@bp.post("/backups")
@admin_required
def api_backup_create():
conn = db.get_db()
u = current_user()
# 打一份整库快照是重活(整库读一遍 + 压缩),别让脚本连打
ok, wait = security.action_allowed("backup:create", 30)
if not ok:
return jsonify({"ok": False, "error": "too_frequent",
"message": "刚打过备份,请 %d 秒后再试" % wait}), 429
note = str(_json_body().get("note") or "").strip()[:200]
try:
r = backup.create(conn, trigger="manual", actor=u["username"], note=note)
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
removed = backup.prune(conn, actor=u["username"])
if removed:
r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed)
r["pruned"] = removed
return jsonify(r)
@bp.get("/backups/<filename>")
@admin_required
def api_backup_download(filename):
"""下载一份归档。文件名必须过 backup.safe_name 的收口。"""
try:
p = backup.path_of(filename)
except backup.BackupError as e:
return _backup_error(e)
if not os.path.exists(p):
return jsonify({"ok": False, "error": "not_found",
"message": "备份文件不存在或已被删除"}), 404
db.audit(db.get_db(), "backup_download", current_user()["username"],
"下载备份 %s" % os.path.basename(p), _ip(), 0)
return send_file(p, as_attachment=True, download_name=os.path.basename(p),
mimetype="application/zip")
@bp.post("/backups/<filename>/restore")
@admin_required
def api_backup_restore(filename):
"""从归档恢复整库。
这是本系统里**破坏性最强**的一个操作:它会把当前所有账号、所有用量、
所有配置替换成归档里的那一份。所以:
* 恢复前自动给当前库打一份 pre-restore 快照(错了能回去)
* 默认把 instance.json 一并恢复(否则 Cookie 密文解不开)
* 完成后所有既有会话失效(密钥与账号可能都变了),必须重新登录
"""
conn = db.get_db()
u = current_user()
body = _json_body()
include_instance = str(body.get("include_instance", "1")).lower() not in ("0", "false", "off", "no")
try:
r = backup.restore(conn, filename, include_instance=include_instance,
actor=u["username"])
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return jsonify(r)
@bp.post("/backups/<filename>/delete")
@admin_required
def api_backup_delete(filename):
conn = db.get_db()
try:
r = backup.delete(conn, filename, actor=current_user()["username"])
except backup.BackupError as e:
return _backup_error(e)
return jsonify(r)
@bp.post("/backups/prune")
@admin_required
def api_backup_prune():
conn = db.get_db()
removed = backup.prune(conn, actor=current_user()["username"])
return jsonify({"ok": True, "removed": removed,
"message": ("已清理 %d 份旧备份" % len(removed)) if removed
else "没有需要清理的备份"})
def _human(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
@@ -344,7 +541,10 @@ def api_settings_get():
for k in [k for k in list(s) if config.is_internal_key(k)]:
s.pop(k, None)
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
s["_canEditGlobal"] = bool(u["is_admin"])
s["_canManageBackups"] = bool(u["is_admin"])
s["_role"] = "admin" if u["is_admin"] else "user"
return jsonify(s)
@@ -363,9 +563,11 @@ def api_settings_post():
if k not in config.DEFAULTS:
errors.append("未知配置项:%s" % k)
continue
if config.is_global_key(k) and not u["is_admin"]:
# 实例级配置(接口基址、注册开关)只有管理员能改 ——
# 否则任意注册用户都能把大家的数据采集指向别的服务器
if not config.writable_by(k, u["is_admin"]):
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
# 或者把 page_size 调到 1000 去 hammer 云端接口。
denied.append(k)
continue
if k == "cookie":
@@ -376,7 +578,7 @@ def api_settings_post():
db.set_secret(conn, "cookie", "", uid)
changed.append(k)
continue
val, err = config.normalize_setting(k, v)
val, err = config.normalize_setting(k, v, conn)
if err:
errors.append(err)
continue
@@ -386,18 +588,35 @@ def api_settings_post():
db.set_setting(conn, k, val, uid)
changed.append(k)
if denied:
errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied)))
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
% "、".join(sorted(denied)))
if errors:
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
request.remote_addr, uid)
_ip(), uid)
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
"errors": errors, "changed": sorted(changed)}), 400
# 调整调度配置后清掉槽位标记,让新时刻立即生效(只清自己的)
if {"schedule_times", "schedule_enabled"} & set(changed):
conn.execute("DELETE FROM settings WHERE user_id=? AND key LIKE ?",
(uid, scheduler.SLOT_PREFIX + "%"))
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
if "schedule_times" in changed:
keep = set(scheduler.slots(conn, 0))
stale = [(r["user_id"], r["key"]) for r in conn.execute(
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
for row_uid, skey in stale:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
# 调小时刻数上限后,多余的槽位标记也该跟着清,否则「缩到 2 个时刻」之后
# 另外几个时刻的标记会一直躺在库里,看着像系统还在按旧配置跑。
if "max_schedule_slots_per_day" in changed:
keep = set(scheduler.slots(conn, 0))
for r in conn.execute("SELECT user_id,key FROM settings WHERE key LIKE ?",
(scheduler.SLOT_PREFIX + "%",)).fetchall():
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?",
(r["user_id"], r["key"]))
db.audit(conn, "settings", u["username"],
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
"修改:" + (",".join(sorted(changed)) or "(无变化)"), _ip(), uid)
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
@@ -416,8 +635,16 @@ def api_password():
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(new), u["id"]))
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"])
return jsonify({"ok": True, "message": "密码已更新"})
# 改密即作废**其他**设备的会话:改密码的动机往往就是怀疑它泄漏了,
# 只改散列却留着旧会话,等于给自己一个假的安心。
db.bump_session_ver(conn, u["id"])
# 当前这次会话跟着刷新到新版本,否则用户改完密码立刻被自己踢下线。
# (安全上「全部踢掉」更好,但体验太差会让人不敢改密码。)
with_row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
session["sv"] = db.session_ver_of(with_row)
db.audit(conn, "password", u["username"], "修改登录密码(其他设备会话已失效)",
_ip(), u["id"])
return jsonify({"ok": True, "message": "密码已更新,其他设备上的登录已失效"})
@bp.post("/profile")
@@ -441,7 +668,7 @@ def api_profile():
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
request.remote_addr, u["id"])
_ip(), u["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -485,7 +712,7 @@ def api_user_create():
(body.get("display_name") or name).strip()[:64],
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
db.audit(conn, "user_create", me["username"],
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"])
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), _ip(), me["id"])
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
@@ -532,6 +759,11 @@ def api_user_update(uid):
return jsonify({"ok": False,
"message": "至少要保留一个启用状态的管理员"}), 400
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
if v != "active":
# 停用必须**连会话一起断**:只改 status 的话,对方手上那台设备
# 要到下一个请求才被拦(也不是不行),但版本号一并推掉更干净 ——
# 将来若有人把 current_user 的状态检查挪走,这里还有一道。
db.bump_session_ver(conn, uid)
changed.append("状态→" + ("启用" if v == "active" else "停用"))
pwd = (body.get("password") or "").strip()
if pwd:
@@ -540,12 +772,15 @@ def api_user_update(uid):
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(pwd), uid))
# 管理员重置口令后,该账号在别处的登录必须立刻失效 ——
# 重置口令的典型场景就是「怀疑账号被盗」。
db.bump_session_ver(conn, uid)
changed.append("密码")
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "user_update", me["username"],
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
request.remote_addr, me["id"])
_ip(), me["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -576,7 +811,7 @@ def api_user_delete(uid):
conn.execute("DELETE FROM users WHERE id=?", (uid,))
db.audit(conn, "user_delete", me["username"],
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
request.remote_addr, me["id"])
_ip(), me["id"])
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
@@ -190,7 +190,9 @@
<div class="navback">
<a class="abtn" href="/">← 返回后台</a>
<a class="abtn" href="/records">数据明细</a>
<a class="abtn" href="/logs">日志管理</a>
<!-- 日志管理仅管理员可达:角色由 /api/manifest 的 role 字段带回,
非管理员时在 boot() 里隐藏,避免给出会 403 的死链 -->
<a class="abtn" id="abtnLogs" href="/logs" hidden>日志管理</a>
</div>
</div>
<div class="meta" data-page-node-id="4jmZuGBGC8JQmVAE2IOJ7H">
@@ -1272,6 +1274,9 @@
$("#err").style.display = "none";
const mf = src.manifest || {};
$("#mUpdate").textContent = mf.generated || "-";
// 管理员入口按角色显隐(大屏是静态页,拿不到 Jinja 上下文)
const logsBtn = $("#abtnLogs");
if (logsBtn) logsBtn.hidden = (mf.role !== "admin");
initDays();
bind();
renderScope();
+7 -1
查看文件
@@ -61,6 +61,12 @@
var out = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
// 只读控件必须跳过:disabled 的 input 仍然出现在 form.elements 里,
// 若被一起提交,服务端会因为「越权修改只读项」把**整单**拒掉 ——
// 表现成「改了 A 却提示 A 不能改」,很难归因。
// 注意:fieldset 被 disable 时子元素自身的 disabled 仍是 false,
// 所以还要往上找祖先 fieldset。
if (el.disabled || (el.closest && el.closest("fieldset[disabled]"))) return;
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
out[el.name] = el.value;
@@ -72,7 +78,7 @@
function hintOf(j) {
if (!j) return "";
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试;「任务管理」里能看到本次运行的状态。";
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
return "";
}
+236
查看文件
@@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}备份管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>备份管理</h1>
<p class="lead">
整库<b>一致性快照</b>:可自动按周期备份、按份数自动清理,也能手工下载与恢复到任意一份
</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
<button class="btn primary" id="btnBackupNow" type="button">立即备份</button>
</div>
</div>
<div id="collectMsg" class="flash" style="display:none"></div>
<div class="kpis">
<div class="kpi" style="--c:var(--cyan)">
<span>归档份数</span><b>{{ info.count }}</b>
<i>保留上限 {{ info.keep }} 份</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>备份占用</span><b>{{ info.total }}</b>
<i>当前库 {{ info.db_bytes }}</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if info.enabled else 'var(--red)' }}">
<span>自动备份</span><b>{{ '已开启' if info.enabled else '已关闭' }}</b>
<i>每 {{ info.interval }} 小时一次</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>上次 / 下次自动备份</span>
<b>{{ info.last_auto[5:16] if info.last_auto != '—' else '还没有' }}</b>
<i>{% if info.never_auto %}等待调度线程首轮检查(约 20 秒)
{%- elif info.next_auto != '—' %}下次 {{ info.next_auto[5:16] }}
{%- else %}未开启{% endif %}</i>
</div>
</div>
<div class="flash warn" style="margin-bottom:16px">
<b>恢复会覆盖当前全部数据</b>(账号、用量、配置一起换成归档里的那一份)。
系统会在恢复前<b>自动先备份一次当前库</b>并保留在列表里,恢复错了可以再恢复到那一份。
恢复完成后所有既有登录会话失效,需要重新登录。
</div>
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>自动备份设置</h2>
<span class="tag accent">实例级</span>
</div>
<form id="formBackup">
{% set b = num_settings %}
<label class="row"><span>启用自动备份</span>
<select name="backup_enabled">
<option value="1" {{ 'selected' if s.backup_enabled != '0' }}>启用</option>
<option value="0" {{ 'selected' if s.backup_enabled == '0' }}>关闭</option>
</select>
</label>
<label class="row"><span>备份周期</span>
<input name="backup_interval_hours" type="number"
min="{{ b.backup_interval_hours[0] }}" max="{{ b.backup_interval_hours[1] }}"
value="{{ s.backup_interval_hours }}">
<em class="unit">{{ b.backup_interval_hours[0] }}~{{ b.backup_interval_hours[1] }} 小时</em></label>
<label class="row"><span>保留份数</span>
<input name="backup_keep" type="number"
min="{{ b.backup_keep[0] }}" max="{{ b.backup_keep[1] }}"
value="{{ s.backup_keep }}">
<em class="unit">份(超出的自动删最旧的)</em></label>
<button class="btn primary" type="submit">保存备份设置</button>
<p class="hint">
自动备份在调度线程里执行,有采集任务在跑时会自动让路、下一轮再打。
备份使用 SQLite 的在线备份 API,<b>采集写入期间也能拿到一致快照</b> ——
这一点是手工 <code>cp usage.sqlite</code> 做不到的。
每份归档打完后按「保留份数」清理最旧的那些。
</p>
</form>
</section>
<section class="card">
<div class="cardhead">
<h2>归档里有什么</h2>
<span class="tag mute">安全提示</span>
</div>
<table class="kv">
<tr><th>备份目录</th><td class="mono" style="word-break:break-all">{{ info.dir }}</td></tr>
<tr><th>内容</th><td>
所有 <code>*.sqlite</code> 的一致性快照<br>
<code>instance.json</code>(含两把主密钥)<br>
<code>manifest.json</code>(时间、行数、积分、逐文件校验值)
</td></tr>
<tr><th>为什么带上密钥</th><td>
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档里的凭证密文
就永远解不开 —— 那样的「恢复」等于把所有账号的 Cookie 弄丢。
</td></tr>
<tr><th>因此</th><td>
<b>归档等于全库数据 + 密钥</b>,下载后请当作机密文件保管;
它<b>不会</b>进入代码仓库、也不会被打进镜像(见 <code>.gitignore</code> /
<code>.dockerignore</code>)。
</td></tr>
<tr><th>目录独立</th><td>
备份目录刻意<b>不在 <code>data/</code> 里面</b>:容器里 <code>data/</code> 是数据卷,
<code>docker compose down -v</code> 会把正本和副本一起删掉。
容器里它挂在独立卷 <code>wb_backups</code> 上。
</td></tr>
</table>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>备份列表</h2>
<span class="hint">新 → 旧,共 {{ rows|length }} 条(含已丢失的条目)</span>
</div>
<div class="tablewrap">
<table class="tbl" id="backupTable">
<thead><tr>
<th>文件名</th><th>生成时间</th><th>来源</th><th class="num">大小</th>
<th class="num">记录</th><th class="num">积分</th><th class="num">账号</th>
<th>库版本</th><th>操作</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr data-file="{{ r.filename }}" data-exists="{{ 1 if r.exists else 0 }}">
<td class="mono sm" style="word-break:break-all">
{{ r.filename }}
{% if not r.exists %}<br><span class="tag bad">文件已丢失</span>{% endif %}
</td>
<td class="mono sm nowrap">{{ r.created_at or '—' }}</td>
<td class="nowrap">
<span class="tag {{ 'accent' if r.trigger == 'manual' else ('warn' if r.trigger == 'pre-restore' else 'mute') }}">
{{ r.trigger }}</span>
{# CLI 造的备份 trigger 与 actor 都是 "cli",印两遍是纯噪音;
只在两者不同时(auto/system、manual/张三)才补一行操作者 #}
{% if r.actor and r.actor != r.trigger %}
<br><span class="muted sm">{{ r.actor }}</span>{% endif %}
</td>
<td class="num nowrap">{{ r.size_h }}</td>
<td class="num">{% if r.records %}{{ '{:,}'.format(r.records) }}{% else %}—{% endif %}</td>
<td class="num">{% if r.credits %}{{ '%.2f'|format(r.credits) }}{% else %}—{% endif %}</td>
<td class="num">{% if r.users %}{{ r.users }}{% else %}—{% endif %}</td>
<td class="mono sm">uv={{ r.schema_ver }}</td>
<td class="nowrap">
{% if r.exists %}
<a class="btn sm ghost" href="{{ url_for('api.api_backup_download', filename=r.filename) }}">下载</a>
<button class="btn sm" type="button" data-act="restore">恢复</button>
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% else %}
<button class="btn sm ghost" type="button" data-act="forget"
title="文件已不在磁盘上,只清掉这条索引记录">移除条目</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="9" class="empty">还没有任何备份。点右上角「立即备份」生成第一份。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">
列表里的「记录 / 积分 / 账号」是归档生成时的计数,用来挑一份合适的恢复 ——
恢复前程序还会再校验一次归档的完整性与库结构版本,校验不过不会动你的数据。
档案文件名只能由本程序生成(服务端会拒绝任何带路径的文件名)。
</p>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formBackup', '/api/settings');
document.getElementById('btnBackupNow').addEventListener('click', function () {
var btn = this, old = btn.textContent;
btn.disabled = true; btn.textContent = '备份中…';
WBU.post('/api/backups', {}).then(function (j) {
if (j.ok === false) { WBU.say(j.message || '备份失败', 'error'); return; }
WBU.say(j.message || '备份完成', 'ok');
window.setTimeout(function () { location.reload(); }, 1200);
}).catch(function (e) {
if (String(e.message) !== 'unauthorized') WBU.say('请求失败:' + e.message, 'error');
}).finally(function () { btn.disabled = false; btn.textContent = old; });
});
document.getElementById('backupTable').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var row = btn.closest('tr'), file = row.dataset.file, act = btn.dataset.act;
var old = btn.textContent;
btn.disabled = true; btn.textContent = '…';
function restore_(finish) {
if (!window.confirm('确定从「' + file + '」恢复吗?\n\n'
+ '当前所有账号、用量数据与配置都会被这份归档替换。\n'
+ '系统会在替换前自动先备份当前库,恢复错了可以再恢复到那一份。')) {
finish(); return;
}
var inc = window.confirm('是否同时恢复 instance.json(含 Cookie 加密主密钥)?\n\n'
+ '点「确定」= 一并恢复(跨机器迁移必须选这个,否则已存的 Cookie 解密不出来)\n'
+ '点「取消」= 只恢复数据库(保留本机现有密钥)');
WBU.post('/api/backups/' + encodeURIComponent(file) + '/restore',
{ include_instance: inc ? '1' : '0' })
.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '恢复失败', 'error'); return; }
WBU.say(j.message || '已恢复', 'ok');
window.setTimeout(function () { location.href = '/'; }, 2500);
})
.catch(function (err) {
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
})
.finally(finish);
}
if (act === 'restore') {
restore_(function () { btn.disabled = false; btn.textContent = old; });
} else if (act === 'del') {
if (!window.confirm('删除备份文件「' + file + '」?此操作不可撤销。')) {
btn.disabled = false; btn.textContent = old; return;
}
WBU.post('/api/backups/' + encodeURIComponent(file) + '/delete', {})
.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '删除失败', 'error'); return; }
WBU.say(j.message || '已删除', 'ok');
window.setTimeout(function () { location.reload(); }, 900);
})
.catch(function (err) { if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
} else if (act === 'forget') {
WBU.say('该文件已不在磁盘上,删除索引条目请用「清理旧备份」或手工删除数据库行。', 'warn');
btn.disabled = false; btn.textContent = old;
}
});
</script>
{% endblock %}
+11 -3
查看文件
@@ -29,7 +29,14 @@
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
<a href="{{ url_for('views.tasks') }}" class="{{ 'on' if nav=='tasks' }}">任务管理</a>
<a href="{{ url_for('views.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
{% if cur.is_admin %}
<a href="{{ url_for('views.backups_page') }}" class="{{ 'on' if nav=='backups' }}">备份管理</a>
{% endif %}
{% if cur.is_admin %}
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% endif %}
{% if cur.is_admin %}
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
{% endif %}
@@ -37,7 +44,8 @@
<div class="me">
<a class="who" href="{{ url_for('views.profile_page') }}" title="个人中心">
<b>{{ cur.display_name }}</b>
{% if cur.is_admin %}<span class="tag accent">管理员</span>{% endif %}
{% if cur.is_admin %}<span class="tag accent">管理员</span>
{% else %}<span class="tag mute">普通账号</span>{% endif %}
</a>
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
@@ -60,8 +68,8 @@
</main>
<footer class="foot">
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 采集在 Web 进程内按各账号配置的时刻执行<br>
每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 调度与采集参数由管理员统一设定<br>
每个账号只使用并只见得到<b>自己的</b> Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
</footer>
<script>
+52 -11
查看文件
@@ -5,7 +5,11 @@
<div class="pagehead">
<div>
<h1>配置管理</h1>
<p class="lead">这里改的都是<b>你自己账号</b>的配置:凭证、采集参数、维护动作;改完立即生效</p>
{% if is_admin %}
<p class="lead">凭证、采集参数、接口地址与注册策略都在这里;改完立即生效</p>
{% else %}
<p class="lead">这里维护<b>你自己账号</b>的云端凭证;采集参数与调度由管理员统一设定,只读展示</p>
{% endif %}
</div>
</div>
@@ -29,6 +33,9 @@
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
(<b>两者必须取自同一次请求</b>),粘贴到下面。
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
{% if not is_admin %}
<br><b>这一块是你唯一可以修改的配置</b> —— 其余参数与调度时刻由管理员统一设定。
{% endif %}
</p>
<form id="formCred">
<label class="col">Cookie
@@ -44,18 +51,21 @@
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>采集参数</h2>
{% if is_admin %}
<span class="tag accent">实例级 · 对所有账号生效</span>
{% else %}
<span class="tag mute">只读 · 仅管理员可改</span>
{% endif %}
</div>
{% if is_admin %}
<form id="formCollect">
{# 实例级键:所有账号共用,只有管理员能改;普通账号只读展示 #}
<label class="row"><span>接口基址</span>
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"
{{ '' if is_admin else 'disabled' }}>
{% if not is_admin %}<em class="unit">实例级,仅管理员可改</em>{% endif %}</label>
<label class="row"><span>接口路径</span>
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"
{{ '' if is_admin else 'disabled' }}>
{% if not is_admin %}<em class="unit">实例级</em>{% endif %}</label>
{% set b = num_settings %}
<label class="row"><span>接口基址</span>
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
<label class="row"><span>接口路径</span>
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
<label class="row"><span>分页大小</span>
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
@@ -82,8 +92,23 @@
</label>
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
<button class="btn primary" type="submit">保存采集参数</button>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。
这些是<b>实例级</b>参数,改一次对本机所有账号生效。</p>
</form>
{% else %}
<p class="hint">这些参数影响采集行为与云端压力,属于整机策略,普通账号只读。</p>
<table class="kv">
<tr><th>接口基址</th><td class="mono">{{ s.api_base }}</td></tr>
<tr><th>接口路径</th><td class="mono">{{ s.api_path }}</td></tr>
<tr><th>分页大小</th><td>{{ s.page_size }} 条/页</td></tr>
<tr><th>断点回退</th><td>{{ s.rewind_minutes }} 分钟</td></tr>
<tr><th>时间漂移容差</th><td>{{ s.drift_tolerance_minutes }} 分钟</td></tr>
<tr><th>Prompt 截断</th><td>{{ s.max_prompt }} 字符{% if s.max_prompt == '0' %}(不截断){% endif %}</td></tr>
<tr><th>整日校验天数</th><td>{{ s.verify_days }} 天</td></tr>
<tr><th>请求超时</th><td>{{ s.timeout }} 秒</td></tr>
<tr><th>校验 TLS 证书</th><td>{% if s.ssl_verify != '0' %}<span class="tag ok">校验</span>{% else %}<span class="tag bad">不校验</span>{% endif %}</td></tr>
</table>
{% endif %}
</section>
<section class="card">
@@ -118,6 +143,22 @@
</section>
</div>
{% if not is_admin %}
<section class="card">
<div class="cardhead">
<h2>为什么采集参数是只读的</h2>
<span class="tag mute">普通账号</span>
</div>
<p class="hint" style="margin:0">
你只能维护<b>本人账号的凭证</b>(Cookie 与 User-Agent)—— 采集始终只用你自己的这份凭证,
拿回来的数据也只会存在你自己的作用域里,别人看不到、你也看不到别人的。
分页大小、超时、接口地址、TLS 校验、调度时刻这些属于<b>整机策略</b>:它们既关系到云端
压力,也关系到所有人的采集是否安全,因此统一由管理员设定。
<br>如果你确实需要调整,把上面任意一项截图给管理员即可。
</p>
</section>
{% endif %}
{% if is_admin %}
<section class="card">
<div class="cardhead">
+4 -3
查看文件
@@ -5,7 +5,7 @@
<div class="pagehead">
<div>
<h1>日志管理</h1>
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
<p class="lead">本机全实例的采集日志、应用运行日志与操作审计(仅管理员可见)</p>
</div>
<div class="actions">
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
@@ -49,12 +49,13 @@
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
<thead><tr><th>#</th><th>账号</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.uname }}</td>
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="nowrap">
@@ -70,7 +71,7 @@
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
<tr><td colspan="11" class="empty">暂无采集日志</td></tr>
{% endfor %}
</tbody>
</table>
+6 -3
查看文件
@@ -58,8 +58,10 @@
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
</table>
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。
{% if current_user().is_admin %}所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员统一设定,
你可以在<a href="{{ url_for('views.tasks') }}">任务管理</a>里查看,也可以随时手动采集本人数据。{% endif %}</p>
</section>
<section class="card">
@@ -98,7 +100,8 @@
<tbody>
{% for r in runs %}
<tr>
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></td>
{# 运行详情在「日志管理」里,而那页仅管理员可达 —— 普通用户不要给死链 #}
<td>{% if current_user().is_admin %}<a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a>{% else %}{{ r.id }}{% endif %}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
+24 -2
查看文件
@@ -10,6 +10,8 @@
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
<a class="btn ghost" href="{{ url_for('views.profile_export') }}"
title="导出你的全部用量明细、采集历史、操作审计与有效配置(不含 Cookie 明文)">导出我的全部数据</a>
</div>
</div>
@@ -57,12 +59,32 @@
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
<button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
且不能是常见弱口令。
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
「怀疑被盗所以改密码」才能真正生效。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>导出我的全部数据</h2>
<span class="tag mute">数据可携带</span>
</div>
<p class="hint" style="margin-top:0">
打包下载<b>属于你账号</b>的所有内容:用量明细、采集历史、与你相关的操作审计、
以及账号信息与对你有效的配置。
<br><b>不含</b> Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、
也不含其他任何账号的数据与实例级运行日志。
</p>
<div class="btnrow">
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
</div>
<p class="hint">导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。</p>
</section>
<section class="card">
<div class="cardhead">
<h2>我的采集凭证</h2>
+35 -6
查看文件
@@ -5,7 +5,7 @@
<div class="pagehead">
<div>
<h1>任务管理</h1>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里能看到本人账号的运行历史,也能手动触发与按区间回填</p>
</div>
<div class="actions">
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
@@ -15,7 +15,11 @@
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>采集调度</h2>
{% if not can_edit %}<span class="tag mute">只读 · 仅管理员可改</span>{% endif %}
</div>
{% if can_edit %}
<form id="formTask">
<label class="row"><span>启用调度</span>
<select name="schedule_enabled">
@@ -26,8 +30,11 @@
<label class="row"><span>每日时刻</span>
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。保存后立即生效,
并会清空当天已执行的槽位标记以便新时刻接管。</p>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
把「每日调度时刻上限」调大。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
@@ -39,7 +46,21 @@
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
</label>
<button class="btn primary" type="submit">保存调度配置</button>
<p class="hint">这套时刻是<b>实例级</b>的:本机上所有账号在同一个时刻各自采集自己的数据。</p>
</form>
{% else %}
<table class="kv">
<tr><th>启用调度</th><td>{% if sch.enabled %}<span class="tag ok">已启用</span>{% else %}<span class="tag bad">已停用</span>{% endif %}</td></tr>
<tr><th>每日时刻</th><td>{{ sch.times | join(' · ') if sch.times else '—' }}</td></tr>
<tr><th>启动补跑</th><td>{% if sch.catch_up %}<span class="tag">开启</span>(宽限 {{ s_grace }} 小时){% else %}关闭{% endif %}</td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
</table>
<p class="hint">
调度时刻与采集参数由<b>管理员统一设定</b>,普通账号只读。
你随时可以在右上角点「立即采集一次」拉取本人账号的最新用量,
也可以在下面按区间补采自己的历史数据 —— 采集始终只用<b>你自己的</b> Cookie。
</p>
{% endif %}
</section>
<section class="card">
@@ -57,11 +78,16 @@
<hr class="sect-divider">
<h3>历史回填</h3>
<form id="formBackfill">
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></label>
<label class="row"><span>起始日期</span>
<input type="date" name="from" max="{{ sch.now[:10] }}"
data-min-days="{{ max_days }}"></label>
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
<button class="btn" type="submit">按区间补采</button>
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p>
<br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
这三条是为了避免把云端接口与本站线程池打满。
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
</form>
</section>
</div>
@@ -96,7 +122,10 @@
<td class="num">{{ r.dup }}</td>
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
<td>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
<td>
{% if can_edit %}<a href="{{ url_for('views.logs', run=r.id) }}">日志</a>
{% else %}<span class="muted">—</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
+250 -44
查看文件
@@ -8,27 +8,36 @@
/ 概览(KPI + 入口)
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
/records 数据明细:分页、筛选、搜索、导出
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
/config 配置管理:本人的 Cookie / UA / 采集参数
/logs 日志管理:采集逐次明细 + 应用日志尾部
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
/logs 日志管理(**仅管理员**)
/profile 个人中心:资料、密码、凭证状态
/users 用户管理(仅管理员)
/register 自助注册(受 allow_register 开关约束)
/captcha.png 图形验证码
**多用户约定**
所有数据类页面都只取 `current_user()["id"]` 那份数据;管理员在
「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
**多用户约定(两条)**
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
"""
import csv
import io
import json
import os
import sqlite3
import tempfile
import zipfile
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
request, send_from_directory, session, url_for)
from .. import collect, config, db, query, scheduler, security
from .. import backup, collect, config, db, query, scheduler, security
from ..security import (admin_required, current_user, is_admin, login_required,
safe_next)
@@ -36,7 +45,13 @@ bp = Blueprint("views", __name__)
def _ip():
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
"""客户端地址。**全站统一走 security.client_ip()**。
原来这里直接取 `X-Forwarded-For` 的第 0 段,等于把「来源 IP」交给请求方
自己申报:验证码出图限速、注册配额、登录锁定三道 IP 防线会一起失效。
具体取法与开关见 security.client_ip 的注释。
"""
return security.client_ip()
def _uid():
@@ -98,10 +113,15 @@ def login():
username = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
# 先记一次「尝试」(含成功)。只按失败计数会被「慢慢撞」绕过:
# 攻击者只要把失败次数控制在阈值以下就能无限试。
security.note_try(ip)
left = security.auth_locked(ip, username)
if left:
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip)
flash("登录失败次数过多,请 %d 秒后再试" % left, "error")
reason = security.auth_block_reason(ip, username)
security.audit_login_fail(conn, username,
"已限速(%s),剩余 %d 秒" % (reason, left), ip)
flash(security.auth_block_message(reason, left), "error")
return render_template("login.html",
**_login_ctx(conn, next_url=nxt, username=username,
need_captcha=True)), 429
@@ -121,7 +141,9 @@ def login():
if user is None:
n = security.note_auth_fail(ip, username)
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
flash("%s(剩余尝试 %d 次)" % (err, max(0, config.MAX_LOGIN_FAILS - n)), "error")
# 不再报「剩余 N 次」:用户名维度的计数已经在攻击者手里了,
# 报出来的数字会变成「还差几次就能把这个人锁住」的倒计时。
flash("%s(本来源连续失败 %d 次)" % (err, n), "error")
# 必须把 next 显式回填:失败后 request.args 为空,
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
return render_template("login.html",
@@ -170,9 +192,11 @@ def register():
ctx = _register_ctx(username=username, display_name=display, email=email,
need_captcha=True)
security.note_try(ip)
left = security.auth_locked(ip, username)
if left:
flash("操作过于频繁,请 %d 秒后再试" % left, "error")
reason = security.auth_block_reason(ip, username)
flash(security.auth_block_message(reason, left), "error")
return render_template("register.html", **ctx), 429
# 注册一律要验证码:这是唯一能让陌生人写库的入口
@@ -307,9 +331,15 @@ def tasks():
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
s = db.get_settings(conn, uid=uid)
pages = max(1, (total + size - 1) // size)
# 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位,
# 而不是让用户填完了再吃一个 400/409/429。
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12",
max_slots=s.get("max_schedule_slots_per_day") or "6",
max_days=collect.max_range_days(conn),
min_gap=collect.min_interval_seconds(conn),
can_edit=is_admin(),
page=page, pages=pages, total=total,
page_window=_page_window(page, pages),
active="tasks")
@@ -393,43 +423,33 @@ def users_page():
active="users")
# ---------------- 日志管理 ----------------
# ---------------- 日志管理(仅管理员) ----------------
@bp.get("/logs")
@login_required
@admin_required
def logs():
conn = db.get_db()
u = current_user()
uid = u["id"]
adm = bool(u["is_admin"])
"""日志管理 —— **仅管理员**。
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
"""
conn = db.get_db()
run_id = request.args.get("run")
detail = None
if run_id and str(run_id).isdigit():
# 明细也必须限本人:否则改一个 ?run= 就能看到别人的采集日志
if adm:
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
(int(run_id),)).fetchone()
else:
detail = conn.execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
(int(run_id), uid)).fetchone()
status = request.args.get("status") or ""
w, p = ("WHERE user_id = ? AND status = ?", [uid, status]) \
if status in ("ok", "warn", "error", "running") else ("WHERE user_id = ?", [uid])
if status in ("ok", "warn", "error", "running"):
w, p = "WHERE status = ?", [status]
else:
status, w, p = "", "", []
# 操作审计:管理员看全部(便于追责),普通用户只看自己触发的
aw, ap = [], []
if not adm:
aw.append("user_id = ?")
ap.append(uid)
act = request.args.get("act") or ""
if act:
aw.append("action = ?")
ap.append(act)
aw_sql = ("WHERE " + " AND ".join(aw)) if aw else ""
# 动作清单的统计基数不能带 action 条件(否则永远只剩一个动作可选)
base = ("WHERE user_id = ?" if not adm else "")
base_p = [uid] if not adm else []
ap = [act] if act else []
aw_sql = "WHERE action = ?" if act else ""
apage = _int_arg("apage", 1, 1, 10 ** 6)
asize = 20
@@ -439,23 +459,25 @@ def logs():
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
actions = conn.execute(
"SELECT action, COUNT(*) n FROM audit_log %s GROUP BY action ORDER BY n DESC, action"
% base, base_p).fetchall()
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 30
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
runs = conn.execute("SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,"
"conflicts,exit_code,message FROM collect_runs %s"
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
p + [size, (page - 1) * size]).fetchall()
apages = max(1, (atotal + asize - 1) // asize)
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
apage_window=_page_window(apage, apages, span=7),
page=page, pages=max(1, (total + size - 1) // size), total=total,
page_window=_page_window(page, max(1, (total + size - 1) // size)),
status=status, audit_all=adm,
active="logs")
status=status, active="logs")
@bp.get("/logs/tail")
@@ -573,6 +595,190 @@ def records_export():
return resp
# ---------------- 备份管理(仅管理员) ----------------
@bp.get("/backups")
@admin_required
def backups_page():
"""备份管理 —— **仅管理员**。
这一页能下载整库归档、也能把整库恢复回某个时刻,权限等价于
「拿到所有人的数据并覆盖它」,所以必须是管理员专属:页面用
@admin_required,接口层另有同样的一层。
"""
conn = db.get_db()
backup.sync_index(conn) # 磁盘才是事实来源,进页面对一次账
rows = backup.listing(conn)
last_auto = backup.last_auto_at(conn)
nxt = backup.next_auto_at(conn)
info = {
"dir": config.BACKUP_DIR,
"count": len([r for r in rows if r["exists"]]),
"total": backup.human(backup.total_bytes(conn)),
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last_auto": last_auto or "—",
# 从来没有跑过自动备份时,next_auto_at() 返回的是**当前时间**
# (语义是「马上就轮到它」)。直接印成时间会让人以为那是个已经过去的
# 计划点,所以这里区分成「还没跑过」与「下次某时刻」两种显示。
"next_auto": nxt.strftime("%Y-%m-%d %H:%M:%S") if (nxt and last_auto) else "—",
"never_auto": not last_auto,
"db_bytes": backup.human(os.path.getsize(config.SQLITE_PATH)
if os.path.exists(config.SQLITE_PATH) else 0),
}
s = db.get_settings(conn)
return render_template("backups.html", rows=rows, info=info, s=s,
num_settings=config.NUM_SETTINGS, active="backups")
# ---------------- 个人数据导出(每个账号都能导自己的) ----------------
def _zip_stream(buf, filename, mimetype="application/zip"):
"""把已生成好的临时缓冲流给浏览器,并在流结束后关掉它。"""
from flask import Response
def gen():
try:
buf.seek(0)
while True:
chunk = buf.read(65536)
if not chunk:
break
yield chunk
finally:
try:
buf.close()
except Exception: # noqa: BLE001
pass
resp = Response(gen(), mimetype=mimetype,
headers={"Content-Disposition": 'attachment; filename="%s"' % filename})
resp.headers["X-Accel-Buffering"] = "no"
return resp
@bp.get("/profile/export")
@login_required
def profile_export():
"""导出「我的全部数据」。
这是普通账号的数据可携带出口,所以**只含本人的数据**,且
**绝不含 Cookie 明文**(只写「有没有配、多少字符、什么时候更新的」)。
归档里放使用记录、采集历史、本人审计与本人配置四份,另加一份说明。
"""
u = current_user()
uid = u["id"]
ok, wait = security.action_allowed("export:%d" % uid, 10)
if not ok:
return render_template("error.html", code=429,
message="导出太频繁了,请 %d 秒后再试" % wait), 429
conn = db.get_db()
buf = tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024)
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
# ---- 1. 使用记录(与官网 xlsx、CSV 导出同构的列)----
with z.open("使用记录.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(collect.FIELDS)
f.write(b.getvalue().encode("utf-8"))
for r in query.iter_records(conn, uid, None, None):
b.seek(0)
b.truncate(0)
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
r["model"], r["client"], r["ts"]])
f.write(b.getvalue().encode("utf-8"))
# ---- 2. 采集历史 ----
with z.open("采集历史.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(["id", "触发方式", "状态", "开始", "结束", "耗时ms",
"窗口起", "窗口止", "云端返回", "新增", "重复", "存档总数",
"冲突", "结论"])
for r in conn.execute(
"SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
"win_to,fetched,added,dup,total,conflicts,message FROM collect_runs"
" WHERE user_id=? ORDER BY id", (uid,)):
b.seek(0)
b.truncate(0)
w.writerow(list(r))
f.write(b.getvalue().encode("utf-8"))
# ---- 3. 本人相关的操作审计 ----
with z.open("操作审计.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(["时间", "操作者", "动作", "说明", "来源 IP"])
for r in conn.execute(
"SELECT at,actor,action,detail,ip FROM audit_log"
" WHERE user_id=? ORDER BY id", (uid,)):
b.seek(0)
b.truncate(0)
w.writerow(list(r))
f.write(b.getvalue().encode("utf-8"))
# ---- 4. 账号与有效配置(凭证只回状态)----
row = db.user_by_id(conn, uid)
st = db.secret_state(conn, "cookie", uid)
cfg = db.get_settings(conn, uid=uid)
for k in [k for k in list(cfg) if config.is_internal_key(k)]:
cfg.pop(k, None)
cfg.pop("cookie", None)
snaps = {k: v for k, v in cfg.items()
if not (isinstance(v, str) and len(v) > 200)}
payload = {
"导出时间": db.now_str(),
"程序版本": _app_version(),
"账号": {
"id": row["id"], "用户名": row["username"],
"显示名": row["display_name"], "邮箱": row["email"],
"角色": "管理员" if row["is_admin"] else "普通账号",
"状态": row["status"], "注册时间": row["created_at"],
"注册来源 IP": row["register_ip"],
"最后登录": row["last_login_at"], "登录次数": row["login_count"],
},
"数据量": {
"记录条数": conn.execute("SELECT COUNT(*) FROM usage_records"
" WHERE user_id=?", (uid,)).fetchone()[0],
"积分合计": round(conn.execute(
"SELECT COALESCE(SUM(credits),0) FROM usage_records"
" WHERE user_id=?", (uid,)).fetchone()[0], 2),
},
"凭证状态": {
"Cookie": ("已配置 %d 字符,尾部 …%s" % (st["chars"], st["tail"])) if st["set"]
else ("无法解密" if st["broken"] else "未配置"),
"说明": "出于安全考虑,导出文件里不含 Cookie 明文;如需迁移请到「配置管理」重新粘贴。",
},
"有效配置": snaps,
}
with z.open("我的账号与配置.json", "w") as f:
f.write(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"))
with z.open("说明.txt", "w") as f:
f.write(("本归档是账号「%s」在本站的全部数据副本。\n\n"
"包含:\n"
" 使用记录.csv —— 你的全部用量明细(与官网导出同构)\n"
" 采集历史.csv —— 你的采集任务运行历史\n"
" 操作审计.csv —— 与你账号相关的操作记录\n"
" 我的账号与配置.json —— 账号信息与对你有有效的配置\n\n"
"不包含:Cookie 明文、任何他人的数据、实例级运行日志。\n"
"导出时间:%s\n程序版本:%s\n"
% (u["username"], db.now_str(), _app_version())).encode("utf-8"))
name = "my-data_%s_%s.zip" % (u["username"], db.now_str()[:10])
db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name,
_ip(), uid)
return _zip_stream(buf, name)
def _app_version():
from .. import __version__
return __version__
# ---------------- 兼容旧地址 ----------------
@bp.get("/index.html")
def legacy_index():