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
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+12
查看文件
@@ -8,6 +8,18 @@
data/ data/
logs/ 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 缓存 # Python 缓存
# 注意:`__pycache__/` 只能匹配上下文**根目录**下的同名目录, # 注意:`__pycache__/` 只能匹配上下文**根目录**下的同名目录,
# 嵌套的(如 tools/__pycache__)必须用 `**/` 前缀——否则会被原样打进镜像。 # 嵌套的(如 tools/__pycache__)必须用 `**/` 前缀——否则会被原样打进镜像。
+44 -3
查看文件
@@ -10,10 +10,50 @@ WB_PORT=8848
TZ=Asia/Shanghai TZ=Asia/Shanghai
# ---------- 首个管理员(只在数据库为空时生效)---------- # ---------- 首个管理员(只在数据库为空时生效)----------
# 强烈建议:首次启动前就设好,避免用默认的 admin/admin123 暴露在局域网上 # 强烈建议:首次启动前就设好。
# 留空时程序会生成**随机**口令,并且只在启动日志里打印一次:
# docker compose logs portal | grep -A1 口令
# 抄下来登录后立刻改掉。(1.4.0 起不再有 admin123 这类硬编码兜底。)
WB_ADMIN_USER=admin WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD= 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。 # 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
WB_DISABLE_SCHEDULER=0 WB_DISABLE_SCHEDULER=0
@@ -34,11 +74,12 @@ WB_IMPORT_XLSX=
# ---------- 镜像名(推送 Gitea 注册表时用)---------- # ---------- 镜像名(推送 Gitea 注册表时用)----------
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest # 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 时有效 ---------- # ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ----------
# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**: # 把数据/日志/备份放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
# Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后, # Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后,
# 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。 # 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。
# WB_HOST_DATA_DIR=./data # WB_HOST_DATA_DIR=./data
# WB_HOST_LOG_DIR=./logs # WB_HOST_LOG_DIR=./logs
# WB_HOST_BACKUP_DIR=./backups
+15 -4
查看文件
@@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime
LABEL org.opencontainers.image.title="WorkBuddy Portal" \ LABEL org.opencontainers.image.title="WorkBuddy Portal" \
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \ org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
org.opencontainers.image.version="1.3.0" \ org.opencontainers.image.version="1.4.0" \
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal" org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
@@ -36,6 +36,7 @@ ENV PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \ PATH="/opt/venv/bin:$PATH" \
WB_DATA_DIR=/app/data \ WB_DATA_DIR=/app/data \
WB_LOG_DIR=/app/logs \ WB_LOG_DIR=/app/logs \
WB_BACKUP_DIR=/app/backups \
WB_HOST=0.0.0.0 \ WB_HOST=0.0.0.0 \
WB_PORT=8848 WB_PORT=8848
@@ -55,16 +56,26 @@ WORKDIR /app
# --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志 # --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志
COPY --chown=app:app . . 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; \ RUN set -eux; \
chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \ chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \
mkdir -p /app/data /app/logs; \ mkdir -p /app/data /app/logs /app/backups; \
chown -R app:app /app/data /app/logs; \ chown -R app:app /app/data /app/logs /app/backups; \
python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)" python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)"
USER app USER app
EXPOSE 8848 EXPOSE 8848
VOLUME ["/app/data", "/app/logs"] VOLUME ["/app/data", "/app/logs", "/app/backups"]
HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \
CMD ["python", "/app/docker/healthcheck.py"] CMD ["python", "/app/docker/healthcheck.py"]
+86 -34
查看文件
@@ -13,10 +13,12 @@
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) | | 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 | | 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 |
| 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) | | 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) |
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理都归管理员 | | 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理、备份都归管理员 |
| 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 | | 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 | | 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 |
| 版本 | v1.3.0 | | 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 |
| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** |
| 版本 | v1.4.0 |
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) | | **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) · **目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
@@ -38,8 +40,9 @@
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 | | **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 | | **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 | | **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属 | | **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属;**访问日志**写进 `logs/app.log`(waitress 本身不记 access log) |
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 | | **备份与恢复** | 备份走 SQLite **在线备份 API**(不是 `cp`,采集正在写也能拿到一致快照),归档成含所有 `*.sqlite` + `instance.json` 的 zip;可设**周期与保留份数**、管理员下载与**一键恢复**(恢复前自动再存一份当前库)、普通用户**导出本人全部数据**。归档不在 `data/` 卷里,`down -v` 不会连备份一起删 |
| **可安全对外** | 容器层限 `cpus`/`mem_limit`/`memswap`/`pids_limit`/`nofile`;实例层限采集最小间隔、每日时刻数、**采集跨度硬顶 31 天**;**有任务在跑时新建任务直接 409**;默认不信任 `X-Forwarded-For`(否则三道 IP 防线全废) |
--- ---
@@ -73,6 +76,7 @@
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 | | 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 |
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) | | 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) |
| 加固 | `workbuddy_portal/crypto.py` + `captcha.py` | 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用**自写 PNG 编码器**出图 | | 加固 | `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/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL,`uid` 是第一个条件 |
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 | | 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
@@ -123,8 +127,10 @@ docker compose up -d
三条路径跑起来后都是:打开 `http://<IP>:8848` → 用管理员登录 → 三条路径跑起来后都是:打开 `http://<IP>:8848` → 用管理员登录 →
**先把密码改掉** → 去「配置管理」粘贴 Cookie。 **先把密码改掉** → 去「配置管理」粘贴 Cookie。
> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里, > 数据、日志与备份放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs` /
> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。 > `_wb_backups`)里,`docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
> **备份刻意独立成一个卷**:与正本同卷时,一次 `down -v` 会把两者一起带走 ——
> 那正好是最需要备份的时刻。
> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下, > **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下,
> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。 > 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。
> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。 > 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。
@@ -132,23 +138,30 @@ docker compose up -d
> **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。 > **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。
> v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密); > v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密);
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级)。 > v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级);
> 两次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。 > v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`),
> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
### 第一次使用必做 ### 第一次使用必做
**管理员:** **管理员:**
1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。 1. **拿到并改掉初始密码**——`WB_ADMIN_PASSWORD` 留空时程序会生成**随机**口令,
2. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效 只在启动日志里打印一次(`docker compose logs portal | grep -A6 管理员初始口令`)。
(这是**实例级**的,对全站账号生效)。 **没有 `admin123` 这类默认口令了**,拿不到就去查看日志。
3. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。 2. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
3. **确认调度与限制**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻;
同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是**实例级**的,
对全站账号生效)。
4. **确认自动备份**——「备份管理」页看状态与保留份数;建议点一次「立即备份」,
并**把一份归档下载到容器之外**(备份留在同一台机器上只防「改错了」,不防「机器没了」)。
**普通账号(注册进来的默认身份):** **普通账号(注册进来的默认身份):**
1. **改密码**——「个人中心 → 修改登录密码」。 1. **改密码**——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。
2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。 2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。
3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。 3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。
### 想给同事开账号? ### 想给同事开账号?
@@ -181,6 +194,9 @@ docker compose up -d
| `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) | | `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
| `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) | | `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL | | `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → 一个 zip),并按保留份数清理最旧的 |
| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间),`--prune` 顺便按保留份数清理 |
| `restore <文件名> --yes` | **从备份恢复**(整表替换)。破坏性操作,必须显式 `--yes`;不加只打印将要发生什么。恢复前会自动把当前库另存一份 |
| `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 | | `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 |
| `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP | | `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP |
| `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 | | `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
@@ -227,9 +243,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ | | `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ |
| `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 | | `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 |
| `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 | | `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 |
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 | ✅ | | `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码(其他设备会话立即失效)、**导出我的全部数据**;点右上角用户名进入 | ✅ |
| `/profile/export` | **导出本人全部数据**(zip:使用记录 / 采集历史 / 操作审计 / 我的账号与配置;**不含 Cookie 明文**),10 秒限速 | ✅ |
| `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 | | `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 |
| `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 | | `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 |
| `/backups` | **备份管理**(**仅管理员**):份数/占用/自动备份状态与下次时间、周期与保留份数设置、列表(下载 / 恢复 / 删除) | ❌ 403 |
![概览](docs/images/01-overview.png) ![概览](docs/images/01-overview.png)
@@ -264,6 +282,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(
| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 | | GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 |
| GET | `/logs/tail` | 应用日志尾部 | **仅管理员** | | GET | `/logs/tail` | 应用日志尾部 | **仅管理员** |
| GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) | | 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 明文) | 登录 |
--- ---
@@ -274,16 +297,16 @@ workbuddy-portal/
├── manage.py 统一 CLI(唯一入口) ├── manage.py 统一 CLI(唯一入口)
├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖) ├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖)
├── Dockerfile 多阶段构建(依赖层 / 运行层) ├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷) ├── docker-compose.yml 单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限)
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux) ├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
├── .env.example 环境变量样例 ├── .env.example 环境变量样例(含资源上限与反代开关)
├── docker/ ├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾) │ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
│ └── healthcheck.py 标准库健康检查(免登录页 /login) │ └── healthcheck.py 标准库健康检查(免登录页 /login)
├── docs/ 文档 + images/(手册配图,合成数据) ├── docs/ 文档 + images/(手册配图,合成数据)
├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/ ├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
├── logs/ 【运行时】app.log(滚动 2 MB × 3) ├── logs/ 【运行时】app.log(滚动 2 MB × 3)
├── backups/ 数据库快照统一落点(整目录被 git 忽略) ├── backups/ 归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略)
├── tools/ ├── tools/
│ ├── smoke.py 离线回归(215 项断言) │ ├── smoke.py 离线回归(215 项断言)
│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号) │ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号)
@@ -300,13 +323,14 @@ workbuddy-portal/
├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头 ├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
├── client.py 云端接口(urllib)+ 编辑器凭证读取 ├── client.py 云端接口(urllib)+ 编辑器凭证读取
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出 ├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑) ├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份)
├── query.py SQL 聚合层(uid 必填) ├── query.py SQL 聚合层(uid 必填)
├── backup.py 备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复)
└── web/ └── web/
├── views.py 页面路由(含 /login /register /captcha.png /profile) ├── views.py 页面路由(含 /login /register /captcha.png /profile /backups)
├── api.py JSON API ├── api.py JSON API
├── templates/ base / login / register / profile / overview / tasks / ├── templates/ base / login / register / profile / overview / tasks /
│ config / logs / records / users / error │ config / logs / records / users / backups / error
└── static/ └── static/
├── css/app.css 统一设计令牌 ├── css/app.css 统一设计令牌
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定 ├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
@@ -316,6 +340,9 @@ workbuddy-portal/
> `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷, > `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。 > `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。
> 容器里它挂的是独立的 `wb_backups` 卷;**绝不用绑定挂载**(Windows 9p 下会让容器
> `unable to open database file`,且不自愈)。该目录同时被 `.gitignore` 与 `.dockerignore` 忽略:
> 归档里含 `instance.json`,进了镜像就等于把整库密钥分发给所有能拉镜像的人。
> >
> 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除), > 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除),
> 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。 > 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。
@@ -342,22 +369,27 @@ workbuddy-portal/
## 安全须知 ## 安全须知
局域网可访问 ⇒ 以下每一条都必要: 局域网甚至公网可访问 ⇒ 以下每一条都必要:
- **必须改默认密码**;给同事发**普通账号**(注册出来的默认身份),不要共用管理员 - **初始口令不是固定的**(v1.4.0 起):`WB_ADMIN_PASSWORD` 留空时程序生成**随机**口令,
——管理员是能停用别人账号的角色。 只在启动日志里打印一次、库里只存散列。**没有 `admin123` 这类默认口令可猜**,
代价是「拿不到那行日志就进不去」——首次启动后请立刻 `docker compose logs portal | grep -A6 管理员初始口令`。
给同事发**普通账号**(注册出来的默认身份),不要共用管理员 —— 管理员是能停用别人账号的角色。
- **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。 - **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。
普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份); 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份);
调度频率、采集参数、注册策略、日志、用户管理全部关死。 调度频率、采集参数、注册策略、日志、用户管理、**备份**全部关死。
**页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与 **页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与
`writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。 `writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。
- **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量); - **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量);
`/api/runs/<id>`、`/api/records/<id>` 这类按 id 取的单条接口也带 `user_id` 约束; `/api/runs/<id>`、`/api/records/<id>` 这类按 id 取的单条接口也带 `user_id` 约束;
`/logs`、`/logs/tail`、`/users`、`/api/users*` 仅管理员。 `/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)密文入库,主密钥在 - **Cookie 静态加密**:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在
`data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同)。 `data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同),
`get_settings()` 把加密键一律置空,要明文只有 `db.get_secret()` 一条路—— POSIX 上该文件权限收到 `0600`。`get_settings()` 把加密键一律置空,要明文只有
这样任何「顺手打印全部配置」的代码都带不出凭证。升级时历史明文会被自动加密。 `db.get_secret()` 一条路——这样任何「顺手打印全部配置」的代码都带不出凭证。
- **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落, - **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落,
否则新账号会「继承」管理员的凭证,属于最严重的串号越权。 否则新账号会「继承」管理员的凭证,属于最严重的串号越权。
- **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除 - **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除
@@ -365,18 +397,38 @@ workbuddy-portal/
- **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号, - **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号,
在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**, 在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**,
下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。 下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。
字模本身带**逐字符随机旋转 / 切变 / 缩放抖动 / 波浪偏移**,同一验证码两次渲染字节差异 **76.9%**。
- **注册受双重限制**:验证码 + 同 IP 每日配额(默认 3 个,可改;`allow_register=0` 可整体关闭)。 - **注册受双重限制**:验证码 + 同 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">` 静默触发)。 - **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— 前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)——
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。 disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。 - **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
- **登录限速**:按 **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次即锁 10 分钟; - **登录限速按两个维度、且强度刻意不同**:IP 维度连续失败 5 次**真锁** 10 分钟;
失败计数表有上限与 TTL;验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。 **用户名维度只做秒级递增退避**(封顶 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`、 - **安全响应头**:CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、
`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。 `Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、 **HSTS 只在真的走 HTTPS 时下发**(`COOKIE_SECURE or FORCE_HTTPS`)——纯 HTTP 部署下发会让
`data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`、`logs/`、`.env`(含明文密码)。 浏览器强升 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` 补救只会多留一个含内容的中间层。
--- ---
+95 -18
查看文件
@@ -2,14 +2,30 @@
## 支持范围 ## 支持范围
本项目按「自托管、局域网内使用」的定位开发。安全修复只针对当前主分支与最新发布版本。 本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、
资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
| 版本 | 是否接受安全修复 | | 版本 | 是否接受安全修复 |
|---|---| |---|---|
| `1.3.x`(当前) | ✅ | | `1.4.x`(当前) | ✅ |
| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) |
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) | | `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) | | `< 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 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以 > **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以
> 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验); > 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验);
> 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留 > 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留
@@ -43,19 +59,26 @@
| 项 | 做法 | 位置 | | 项 | 做法 | 位置 |
|---|---|---| |---|---|---|
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` | | 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum` | `security.admin_required` | | 角色(两档) | 管理员 / 普通。**仅管理员**:`/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` | | **写权限只有一条规则** | `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` | | **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` |
| 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` | | 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` |
| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` | | 停用即失效 | `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` | | 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` |
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` | | CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` | | 会话签名 | 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` | | 会话 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` | | 开放重定向 | 登录后的 `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` | | 响应头 | 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` |
### 多用户数据隔离 ### 多用户数据隔离
@@ -89,17 +112,46 @@
| 项 | 做法 | 位置 | | 项 | 做法 | 位置 |
|---|---|---| |---|---|---|
| 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` | | 图形验证码 | 手写 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` | | 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` |
| 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` | | 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` |
| 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` | | 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` |
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径) | `security.captcha_fetch_allowed` | | 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` |
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭 | `security.register_quota` | | 注册配额 | 同 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` | | 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` | | 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
@@ -110,8 +162,13 @@
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。 `.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。 注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
**也绝不进镜像**:`.dockerignore` 里 `backups/` 是**硬要求**,理由见上表(P0-3)。
`Dockerfile` 里那条构建期断言就是防「以后有人又把它删了」。
> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷, > `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。 > `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。
> 容器里它挂独立卷 `wb_backups`;**绝不用绑定挂载**(Windows 9p 下容器会
> `unable to open database file`,且不自愈)。
## 已知的**非**目标(部署方需自行处理) ## 已知的**非**目标(部署方需自行处理)
@@ -120,28 +177,48 @@
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时 - **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。 **不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。 注意:纯 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 之后。 - **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。 应用层已有采集/导出/恢复等重操作的最小间隔,但那只是「别自己把自己打满」,
挡不住分布式来源。
- **备份策略只做到「本机自动 + 手动下载」**:程序会按周期打快照、按份数清理、支持下载与恢复,
但**不会**把归档推到异地。**备份留在同一台机器上只防「改错了」,不防「机器没了」** ——
请自行把归档同步到别处(那是 3-2-1 原则里属于你的那一半)。
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。 - **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
- **不建议直接暴露到公网**:设计前提是局域网或 VPN 内使用。 - **不建议在没有任何前置防护时直接暴露到公网**:1.4.0 起 IP 来源、限速、资源上限、
采集跨度硬顶都补上了,但设计前提仍是「局域网或 VPN 内使用 + 前面有反代」。
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。 - **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。 它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。 - **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
**备份归档里也有一份 `instance.json`** —— 归档的保密等级与 `instance.json` 完全相同。
- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志, - **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志,
理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 —— 理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 ——
界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。 界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。
**管理员还能下载备份与执行恢复** —— 这等于对全库数据的完整读写权,授管理员前请想清楚。
## 部署前的最小检查清单 ## 部署前的最小检查清单
- [ ] 已修改默认管理员口令(`WB_ADMIN_PASSWORD`),不再是 `admin123` - [ ] **未设 `WB_ADMIN_PASSWORD` 时已从启动日志抄下随机初始口令并改掉**
(`docker compose logs portal | grep -A6 管理员初始口令`)—— 现在**没有** `admin123` 兜底
- [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip` - [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip`
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥) - [ ] `data/`、`logs/`、`backups/` 的权限只对服务账号可读写(前两者与 `instance.json` 同级机密)
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1` - [ ] 前面有反向代理并启用了 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` 没有被提交到任何仓库 - [ ] 确认 `data/instance.json` 没有被提交到任何仓库
- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.sqlite`) - [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.zip`)
- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待 - [ ] **确认镜像里没有备份归档**:`docker run --rm <镜像> sh -c 'ls -A /app/backups'` 应为空
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人才给管理员 - [ ] 已规划**异地**备份:程序只负责本机打快照,归档需自行同步到别的机器/对象存储
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人(含**下载备份与恢复**)才给管理员
- [ ] 已把「采集最小间隔 / 单次最长跨度 / 每日时刻上限」三个刹车调到符合你的预期
(「任务管理」页可改;跨度硬顶 31 天不可突破)
- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 - [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
「已保存但无法解密」 「已保存但无法解密」
- [ ] 升级到 1.3.0 后确认「任务管理」里调度时刻**对普通账号是只读的** - [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**,
(用普通账号点一次「保存」应被拒并点名越权项) 且访问 `/backups` 返回 403(用普通账号各试一次)
+4
查看文件
@@ -19,3 +19,7 @@ services:
volumes: volumes:
- ${WB_HOST_DATA_DIR:-./data}:/app/data - ${WB_HOST_DATA_DIR:-./data}:/app/data
- ${WB_HOST_LOG_DIR:-./logs}:/app/logs - ${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 container_name: workbuddy-portal
restart: unless-stopped restart: unless-stopped
init: true # tini 接管 PID 1:docker stop 能干净地传到 python 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: ports:
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848" - "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
environment: environment:
@@ -35,16 +57,37 @@ services:
WB_HOST: 0.0.0.0 WB_HOST: 0.0.0.0
WB_PORT: "8848" WB_PORT: "8848"
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin} WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
# 留空时程序会生成**随机**口令并只在启动日志里打印一次(不再有 admin123 兜底),
# 所以首次部署要去 `docker compose logs portal | grep 口令` 抄一次。
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-} WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0} 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 是否只走 HTTPS。纯 HTTP 部署必须留 0:设成 1 时浏览器
# 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。 # 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0} 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_CREDS: ${WB_IMPORT_CREDS:-0}
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-} WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
volumes: volumes:
- wb_data:/app/data # 数据正本 + 导出 + secret_key - wb_data:/app/data # 数据正本 + 导出 + secret_key
- wb_logs:/app/logs # 应用日志(滚动 2 MB × 3) - wb_logs:/app/logs # 应用日志(滚动 2 MB × 3)
# 备份**单独一个卷**:备份与正本同卷时,一次 `down -v` 或卷损坏会把
# 两者一起带走 —— 那正是最需要备份的时刻。分开挂才有意义。
- wb_backups:/app/backups
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie # 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro # - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
healthcheck: healthcheck:
@@ -62,3 +105,8 @@ services:
volumes: volumes:
wb_data: wb_data:
wb_logs: 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() { 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. 初始化(幂等:users 非空时不会重建管理员)----------
# 口令策略(1.4.0 起):WB_ADMIN_PASSWORD 为空时**没有**默认口令,
# manage.py init 会生成一个随机口令并只打印一次 —— 抄下来,否则进不去。
if [ -n "${ADMIN_PASSWORD}" ]; then if [ -n "${ADMIN_PASSWORD}" ]; then
python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}" python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}"
else else
log "未设置 WB_ADMIN_PASSWORD。若这是首次初始化,下面「管理员初始口令」一行就是"
log " 唯一的获取机会(程序不保存明文,库里只有散列,找不回来):"
log " docker compose logs portal | grep -A2 '管理员初始口令'"
log " 已在运行过一次的实例上,这一行不会再出现(管理员早就建好了)。"
python manage.py init --user "${ADMIN_USER}" python manage.py init --user "${ADMIN_USER}"
log "未设置 WB_ADMIN_PASSWORD —— 首次部署的默认密码是 admin123,请登录后立刻修改"
fi fi
# ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ---------- # ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ----------
+188
查看文件
@@ -8,6 +8,194 @@
--- ---
## [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 ## [1.3.0] — 2026-09-16
**主题:权限收敛 · 配置作用域统一 · 目录规范化** **主题:权限收敛 · 配置作用域统一 · 目录规范化**
+360 -79
查看文件
@@ -90,17 +90,26 @@ workbuddy-portal/
**① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。 **① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。
```bash ```bash
# 默认是 admin / admin123 —— 局域网部署下必须改掉!
python manage.py init --user admin --password '你的强密码' python manage.py init --user admin --password '你的强密码'
``` ```
> **不设密码会怎样**(v1.4.0 起):程序用 `secrets.token_urlsafe(12)` 生成一个**随机**口令,
> 用醒目的方框打印在输出里,**只在这一次打印**。库里只有散列,日志一滚就再也拿不回来。
> **没有 `admin123` 这类默认口令了** —— 硬编码一个默认口令等于把公网实例的钥匙挂在门上。
> 所以要么现在就显式设 `WB_ADMIN_PASSWORD`,要么把打印出来的那行抄走。
> 容器里的对应做法见 [11.10 拿不到管理员初始口令](#1110-拿不到管理员初始口令)。
**② 访问方式。** 决定 `WB_BIND` 与 `WB_COOKIE_SECURE`: **② 访问方式。** 决定 `WB_BIND` 与 `WB_COOKIE_SECURE`:
| 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | | 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | `WB_TRUST_PROXY` |
|---|---|---| |---|---|---|---|
| 只本机用 | `127.0.0.1` | `0` | | 只本机用 | `127.0.0.1` | `0` | `0` |
| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | | 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | **`0`** |
| 域名 + HTTPS 反代 | `127.0.0.1` | `1` | | 域名 + HTTPS 反代 | `127.0.0.1` | `1` | **`1`**(前提:反代会写 XFF,见第六节) |
> `WB_TRUST_PROXY` 是 v1.4.0 新增的,**默认 0**。直接暴露给公网时留 0;
> 设成 1 而反代又没写该头,攻击者每次换一个伪造的 `X-Forwarded-For` 就能让
> 验证码限速 / 注册配额 / 登录锁定**三道防线同时失效**(实测 45 次请求全部放行)。
### 2.4 从 v1.0 迁移历史数据(可选) ### 2.4 从 v1.0 迁移历史数据(可选)
@@ -157,16 +166,28 @@ WorkingDirectory=/opt/workbuddy-portal
# TZ 直接决定调度时刻与所有日期口径,务必设对 # TZ 直接决定调度时刻与所有日期口径,务必设对
Environment=TZ=Asia/Shanghai Environment=TZ=Asia/Shanghai
Environment=WB_COOKIE_SECURE=0 Environment=WB_COOKIE_SECURE=0
# 备份落点默认就是 <仓库>/backups,显式写出来便于以后搬迁
Environment=WB_BACKUP_DIR=/opt/workbuddy-portal/backups
# 直连(前面没有反代)时保持 0 —— 设成 1 会让三道 IP 防线可被伪造 XFF 绕过
Environment=WB_TRUST_PROXY=0
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848 ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
Restart=always Restart=always
RestartSec=5 RestartSec=5
# ---- 资源上限(与容器部署的 cpus / mem_limit 对应)----
# 单写者架构下不靠加副本扛负载,所以「限制单实例吃多少」才是正解。
# 数值按需调整:内存超限被 OOM 杀掉有明确日志,比「越来越慢」好查得多。
CPUQuota=100%
MemoryMax=512M
MemorySwapMax=0
TasksMax=64
# ---- 加固(可选但建议)---- # ---- 加固(可选但建议)----
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs /opt/workbuddy-portal/backups
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -179,11 +200,15 @@ sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f journalctl -u workbuddy-portal -f
``` ```
> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/` 归它所有: > `User=workbuddy` 需要事先建号,并让 `data/`、`logs/`、`backups/` 归它所有:
> ```bash > ```bash
> sudo useradd --system --home /opt/workbuddy-portal --shell /usr/sbin/nologin workbuddy > sudo useradd --system --home /opt/workbuddy-portal --shell /usr/sbin/nologin workbuddy
> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data /opt/workbuddy-portal/logs > sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data \
> /opt/workbuddy-portal/logs \
> /opt/workbuddy-portal/backups
> ``` > ```
> `backups/` 千万别漏 —— 漏了的表现是「备份管理」页能打开,但一备份就报错,
> 日志里是 `PermissionError`(服务以 `workbuddy` 身份跑,写不进 root 拥有的目录)。
### 3.2 Windows ### 3.2 Windows
@@ -266,20 +291,54 @@ docker compose logs -f
### 4.4 `.env` 主要变量 ### 4.4 `.env` 主要变量
#### 监听与调度
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|---|---|---| |---|---|---|
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` | | `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` |
| `WB_PORT` | `8848` | 宿主机端口 | | `WB_PORT` | `8848` | 宿主机端口 |
| `TZ` | `Asia/Shanghai` | **影响调度时刻与所有日期口径** | | `TZ` | `Asia/Shanghai` | **影响调度时刻与所有日期口径** |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集)。多副本时除第一个外都要设 1 |
| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) | | `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) |
| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 | | `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空则随机生成并只在启动日志打印一次**(v1.4.0 起不再有 `admin123` 兜底) |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** | | `WB_THREADS` | `4`(compose)/ `8`(代码) | waitress 线程数 = 单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx |
| `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) | | `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) |
> `TZ`、`WB_COOKIE_SECURE`、`WB_DISABLE_SCHEDULER` 都是**进程环境变量**, #### 容器资源上限(v1.4.0 起)
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_CPUS` | `1.0` | CPU 上限(允许小数)。SQLite 是单写者,1 核够用,压测后再调 |
| `WB_MEM_LIMIT` | `512m` | 内存上限。**同时会设 `memswap_limit` 为同值 = 禁用 swap** |
| `WB_PIDS_LIMIT` | `256` | 进程/线程数上限,挡 fork 炸弹 |
> **为什么 swap 要禁用**:不禁用的话内存超限会悄悄滑进 swap,表现为「越来越慢」
> 而不是「被 OOM 杀掉」——后者有明确的日志和退出码,前者只会让人怀疑磁盘。
> `nofile` 上限(4096:8192)在 compose 里写死,因为 SQLite 除主库外还持有 `-wal` `-shm`,
> 恢复期间还要开临时库 + `ATTACH` 源库,默认 1024 在高并发下偏紧。
#### 反向代理与传输安全(**三个必须一起决定**)
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_TRUST_PROXY` | `0` | 是否信任 `X-Forwarded-For`。**默认不信任**。设 1 的前提是「你自己的反代会写这个头」(见第六节,两种 nginx 写法都安全) |
| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转到 https(POST 不跳,否则会掉请求体) |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** |
| `WB_ACCESS_LOG` | `1` | 是否记访问日志(写进 `logs/app.log`,跳过静态资源与验证码图) |
> **HSTS 的触发条件正是 `WB_COOKIE_SECURE` 或 `WB_FORCE_HTTPS` 为 1** ——
> 所以纯 HTTP 部署不会下发 HSTS(下发会让浏览器强升 https,表现成白屏)。
#### 可选:启动时自动导入
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx |
| `WB_BACKUP_DIR` | `/app/backups` | 归档落点(相对**仓库目录**,不是 `data/`,理由见 [8.1](#81-备份什么)) |
> `TZ`、`WB_COOKIE_SECURE`、`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、
> `WB_THREADS`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` 都是**进程环境变量**,
> `docker compose restart` **不生效**,必须 `docker compose up -d` 重建容器。 > `docker compose restart` **不生效**,必须 `docker compose up -d` 重建容器。
### 4.5 数据落点:命名卷,不是绑定挂载 ### 4.5 数据落点:命名卷,不是绑定挂载
@@ -288,6 +347,13 @@ docker compose logs -f
|---|---|---| |---|---|---|
| `/app/data` | 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(密钥)、`exports/` | | `/app/data` | 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(密钥)、`exports/` |
| `/app/logs` | 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) | | `/app/logs` | 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) |
| `/app/backups` | 命名卷 `workbuddy-portal_wb_backups` | 备份归档 `usage-<时间戳>.zip`(**含 `instance.json`,机密等级等同密钥**) |
> **备份为什么必须单独一个卷**:如果和 `wb_data` 同卷,一次 `docker compose down -v`
> 或卷损坏会把正本与备份**一起**带走 —— 那正好是最需要备份的时刻。
> 而且容器里的 `/app/backups` 若不挂卷,写进去的文件只存在于容器**可写层**:
> `docker restart` 还能留住,但 `docker compose up -d --build` 重建容器就没了 ——
> 「构建完发现备份全丢」是个会真的踩到的坑。现在挂的是独立命名卷。
**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的): **为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的):
@@ -308,33 +374,41 @@ docker compose exec portal python manage.py passwd admin 新密码
docker compose logs -f portal docker compose logs -f portal
``` ```
#### 想让宿主机直接看到数据 / 日志 #### 想让宿主机直接看到数据 / 日志 / 备份
叠加 `docker-compose.hostdir.yml`: 叠加 `docker-compose.hostdir.yml`:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d
# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录 # 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR / WB_HOST_BACKUP_DIR 指定目标目录
docker volume rm workbuddy-portal_wb_backups # 叠加后这个卷没人用,可删
``` ```
> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器同一文件系统,WAL 语义正常)。 > ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器同一文件系统,WAL 语义正常)。
> Windows + Docker Desktop 下必然踩上面那个坑。 > Windows + Docker Desktop 下必然踩上面那个坑。
> >
> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 > Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000
> 不一致:`sudo chown -R 1000:1000 ./data ./logs` > 不一致:`sudo chown -R 1000:1000 ./data ./logs ./backups`
### 4.6 常用命令 ### 4.6 常用命令
```bash ```bash
docker compose ps docker compose ps
docker compose logs -f --tail=100 docker compose logs -f --tail=100
docker compose logs portal | grep -A6 '管理员初始口令' # 首次初始化时唯一一次机会
docker compose restart # 注意:环境变量改动 restart 不生效 docker compose restart # 注意:环境变量改动 restart 不生效
docker compose down # 停并删容器,数据保留在卷里 docker compose down # 停并删容器,数据保留在卷里
docker compose down -v # ⚠️ 连卷一起删(数据 + 日志 + 备份全没)
docker compose up -d --build # 改完代码重新构建 docker compose up -d --build # 改完代码重新构建
docker compose exec portal python manage.py collect # 手动采集一次 docker compose exec portal python manage.py collect # 手动采集一次
docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态
docker compose exec portal python manage.py status # 调度与最近采集 docker compose exec portal python manage.py status # 调度与最近采集
docker compose exec portal python manage.py backups # 现有备份清单
# 看容器实际吃到的资源上限(验证 compose 的 limits 生效了)
docker stats --no-stream workbuddy-portal
docker inspect -f 'CPU={{.HostConfig.NanoCpus}} MEM={{.HostConfig.Memory}} PIDS={{.HostConfig.PidsLimit}}' workbuddy-portal
``` ```
--- ---
@@ -363,10 +437,19 @@ name: workbuddy-portal
services: services:
portal: portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
container_name: workbuddy-portal container_name: workbuddy-portal
restart: unless-stopped restart: unless-stopped
init: true # tini 接管 PID 1,docker stop 能干净传到 python init: true # tini 接管 PID 1,docker stop 能干净传到 python
# ---- 容器资源上限(对外提供服务时的第一道闸门)----
cpus: "${WB_CPUS:-1.0}" # SQLite 单写者,多给核也并行不起来
mem_limit: "${WB_MEM_LIMIT:-512m}"
memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap
pids_limit: ${WB_PIDS_LIMIT:-256}
ulimits:
nofile: {soft: 4096, hard: 8192}
ports: ports:
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848" - "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
environment: environment:
@@ -378,10 +461,17 @@ services:
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0} WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
# 纯 HTTP 部署必须留 0,设成 1 会「登录成功又跳回登录页」 # 纯 HTTP 部署必须留 0,设成 1 会「登录成功又跳回登录页」
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0} WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0}
# 直接暴露公网必须留 0;只有自己的反代重写了 XFF 才置 1(见第六节)
WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0}
WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0}
WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1}
WB_THREADS: ${WB_THREADS:-4}
WB_BACKUP_DIR: /app/backups
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
volumes: volumes:
- wb_data:/app/data # 数据正本 + instance.json + exports - wb_data:/app/data # 数据正本 + instance.json + exports
- wb_logs:/app/logs - wb_logs:/app/logs
- wb_backups:/app/backups # 归档单独一个卷,别和正本同卷
healthcheck: healthcheck:
test: ["CMD", "python", "/app/docker/healthcheck.py"] test: ["CMD", "python", "/app/docker/healthcheck.py"]
interval: 30s interval: 30s
@@ -397,6 +487,7 @@ services:
volumes: volumes:
wb_data: wb_data:
wb_logs: wb_logs:
wb_backups:
``` ```
**`.env`**: **`.env`**:
@@ -408,13 +499,22 @@ WB_BIND=0.0.0.0
WB_PORT=8848 WB_PORT=8848
TZ=Asia/Shanghai TZ=Asia/Shanghai
# 首个管理员(只在数据库为空时生效)—— 务必改掉默认值 # 首个管理员(只在数据库为空时生效)。
# 留空 = 程序生成随机口令并只在启动日志打印一次(没有 admin123 兜底了)。
WB_ADMIN_USER=admin WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD=换成你的强密码 WB_ADMIN_PASSWORD=换成你的强密码
WB_DISABLE_SCHEDULER=0 WB_DISABLE_SCHEDULER=0
WB_COOKIE_SECURE=0 WB_COOKIE_SECURE=0
WB_IMPORT_CREDS=0 WB_TRUST_PROXY=0
WB_FORCE_HTTPS=0
WB_ACCESS_LOG=1
WB_THREADS=4
# 容器资源上限
WB_CPUS=1.0
WB_MEM_LIMIT=512m
WB_PIDS_LIMIT=256
EOF EOF
chmod 600 .env # 里面有密码 chmod 600 .env # 里面有密码
``` ```
@@ -434,12 +534,30 @@ docker compose logs -f --tail=50
首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`): 首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`):
1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建); 1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建)。
没设 `WB_ADMIN_PASSWORD` 时,这一步会打印一次随机口令,格式长这样:
```
================= 管理员初始口令 =================
用 户 名:admin
初 始 口 令:xxxxxxxxxxxxxxxx
==================================================
```
**只有这一次机会**。之后想再看到,唯一的办法是删库重来或
`docker compose exec portal python manage.py passwd admin 新密码`。
2. 可选导入 —— `WB_IMPORT_CREDS=1` / `WB_IMPORT_XLSX=…` 才触发; 2. 可选导入 —— `WB_IMPORT_CREDS=1` / `WB_IMPORT_XLSX=…` 才触发;
3. `exec manage.py serve` —— 交接给 waitress,调度线程就在这个进程里。 3. `exec manage.py serve` —— 交接给 waitress(线程数 `WB_THREADS`),调度线程就在这个进程里。
日志里看到 `[entrypoint] 启动 Web 服务(waitress)…` 就是起来了。 日志里看到 `[entrypoint] 启动 Web 服务(waitress)…` 就是起来了。
> 口令提示行是被容器启动日志带出来的,所以**别忘了加 `--tail`**
> (默认 `docker compose logs` 只给最近若干行;容器重启多轮后提示会被挤出去):
>
> ```bash
> docker compose logs portal | grep -A6 '管理员初始口令'
> ```
### 5.4 完成后的检查清单 ### 5.4 完成后的检查清单
```bash ```bash
@@ -473,7 +591,10 @@ docker compose exec portal python manage.py status
## 六、反向代理与 HTTPS ## 六、反向代理与 HTTPS
前面挂 nginx / Caddy / Traefik 时,有两点必须注意,否则会踩坑: 前面挂 nginx / Caddy / Traefik 时有三个要点,**第 3 条是 v1.4.0 新增的**,
漏了会以「登录限速误伤所有人」的形式出现:
**① nginx 侧透传真实 IP**
```nginx ```nginx
server { server {
@@ -487,8 +608,7 @@ server {
proxy_pass http://127.0.0.1:8848; proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上, # 透传真实 IP:登录失败限速、注册配额、验证码限速都按它计数
# 一个人被锁 → 全站被锁
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
@@ -499,14 +619,47 @@ server {
} }
``` ```
| 现象 | 原因 | **② 应用侧打开 `WB_TRUST_PROXY=1`(关键,别漏)**
程序**默认不信任** `X-Forwarded-For` —— 这是为了「直接暴露公网」时的安全:
XFF 的第一段是客户端自己填的,信了它,攻击者每次换一个伪造值就能绕过全部 IP 限速。
开启后程序取 XFF 里**最右侧**的合法 IP。最右侧是离你最近的那一跳(由你自己的代理写入),
客户端加不进去,所以下面两种 nginx 写法**都安全**:
| nginx 写法 | 效果 |
|---|---| |---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` | | `$proxy_add_x_forwarded_for` | 在客户端传来的值后面**追加**你的 `$remote_addr` → 链路完整,便于排查 |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` | | `$remote_addr` | **覆盖**成最近一跳 → 最保守,丢掉链路 |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
真正不能做的是去信 XFF 里**最左边**那一段。
```bash
# .env
WB_TRUST_PROXY=1
```
> ⚠️ **漏了这一步的症状**:所有请求都以 `127.0.0.1`(代理的地址)计入限速,
> 于是**一个人失败 5 次,全站被锁 10 分钟**。这与「没透传 XFF」的表现一模一样,
> 所以两件事要一起改。
>
> 反过来,**不设反代却设了 `WB_TRUST_PROXY=1`** 是更危险的方向:
> 三道 IP 防线直接失效。拿不准就留 `0`。
**③ HTTPS 相关开关**
配好 HTTPS 后,把 `.env` 里的 `WB_COOKIE_SECURE` 改成 `1`(会话 Cookie 只走 HTTPS), 配好 HTTPS 后,把 `.env` 里的 `WB_COOKIE_SECURE` 改成 `1`(会话 Cookie 只走 HTTPS),
然后 `docker compose up -d` 重建容器。**只有真的能用 `https://` 访问时才这么做。** 需要强制跳转再加 `WB_FORCE_HTTPS=1`。这两个任一为 `1` 时程序才下发 HSTS
—— 纯 HTTP 部署**不会**下发(下发会让浏览器强升 https,表现成白屏)。
改完都要 `docker compose up -d` 重建容器(环境变量在启动期读取)。
| 现象 | 原因 |
|---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For`,**或**忘了设 `WB_TRUST_PROXY=1` |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
| 访问 http 时白屏 / 一直跳转 | `WB_FORCE_HTTPS=1` 但实际没有 HTTPS 可用 |
--- ---
@@ -522,7 +675,7 @@ server {
```bash ```bash
export GITEA_TOKEN=<你的令牌> export GITEA_TOKEN=<你的令牌>
tools/push-all.sh # 推 main 分支 + 镜像 latest tools/push-all.sh # 推 main 分支 + 镜像 latest
tools/push-all.sh 1.3.0 # 同时打一个版本 tag 并推送 tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送
``` ```
脚本做的事: 脚本做的事:
@@ -560,15 +713,15 @@ docker login git.iwali.top -u wangchuanli
docker compose build docker compose build
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \ docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
``` ```
### 7.4 验证远端 ### 7.4 验证远端
```bash ```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
# 或 # 或
curl -s -u wangchuanli:TOKEN \ curl -s -u wangchuanli:TOKEN \
https://git.iwali.top/api/v1/packages/wangchuanli?type=container https://git.iwali.top/api/v1/packages/wangchuanli?type=container
@@ -589,17 +742,47 @@ curl -s -u wangchuanli:TOKEN \
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** | | `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** |
| `exports/*.csv` | ★ | 导出快照,可再生 | | `exports/*.csv` | ★ | 导出快照,可再生 |
| `wb_logs` 卷 | ☆ | 排错用,可再生 | | `wb_logs` 卷 | ☆ | 排错用,可再生 |
| `wb_backups` 卷(`/app/backups`) | ★★★ | 备份归档 `usage-<时间戳>.zip`。**里面含一份 `instance.json`** —— 所以它的机密等级和密钥文件完全相同 |
`.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。 `.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。
工程目录下的 `backups/` 就是给这类快照准备的位置(**刻意不放在 `data/`**: > **`instance.json` 为什么必须跟着备份走**:`cookie_key` 在里面,而各账号的 Cookie
`data/` 是 Docker 卷,`docker compose down -v` 会把备份和正本一起删掉)。 > 是用它加密后入库的。只备库不备它,恢复出来的库能读,但**所有账号的 Cookie 都会
> 显示「无法解密」,需要各自重填**。所以 v1.4.0 的归档里默认打包了它
> (恢复时可以关掉,见 [8.3](#83-恢复))。
容器里归档落在**独立的命名卷** `workbuddy-portal_wb_backups`(对应 `/app/backups`),
**刻意不与 `data/` 同卷**:`docker compose down -v` 会把同卷的正本与副本一起删掉 ——
那正好是最需要备份的时刻。裸机部署下就是工程目录里的 `backups/`。
> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 [4.5](#45-数据落点命名卷不是绑定挂载) 与 [11.5](#115-windows-绑定挂载的坑容器打不开数据库))。 > **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 [4.5](#45-数据落点命名卷不是绑定挂载) 与 [11.5](#115-windows-绑定挂载的坑容器打不开数据库))。
### 8.2 备份 ### 8.2 备份
**方式 A:整体打包命名卷(最完整,升级前做)** **方式 A:用内置备份(推荐,v1.4.0 起)**
三种触发方式,产出的是同一种归档:
| 方式 | 怎么做 |
|---|---|
| 自动 | 「备份管理」页开 `backup_enabled`,设周期(默认 24 小时)与保留份数(默认 7)。调度线程每 20 秒检查一次,**有采集在跑就跳过**(不跟采集抢磁盘),到期自动打一份并按份数清理最旧的 |
| 页面 | 「备份管理 → 立即备份」,列表里可**下载**(拿 zip)、**恢复**、**删除** |
| 命令行 | `docker compose exec portal python manage.py backup --note "升级前"` |
```bash
# 列出已有备份(大小 / 条数 / 积分 / 来源 / 时间),--prune 顺便清理
docker compose exec portal python manage.py backups
docker compose exec portal python manage.py backups --prune --keep 5
# 把归档拿到宿主机(备份留在容器里不算备份)
docker compose cp portal:/app/backups/. ./backups/
```
> **归档里的快照不是 `cp` 出来的**:走的是 SQLite 在线备份 API(`Connection.backup`),
> 按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
> 直接 `cp data/usage.sqlite` 可能缺最近一段数据而**不报错**,这是最容易被忽略的差别。
**方式 B:整体打包命名卷(升级前做,最完整)**
```bash ```bash
docker compose stop portal docker compose stop portal
@@ -612,30 +795,6 @@ docker run --rm \
docker compose start portal docker compose start portal
``` ```
**方式 B:只导数据库文件(最常用,每天做)**
```bash
# 先 checkpoint 把 WAL 落进主库,再拷 —— 这样单独一个 .sqlite 就是完整的
docker compose exec portal python -c "
from workbuddy_portal import db
db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')"
docker run --rm \
-v workbuddy-portal_wb_data:/data:ro \
-v "$PWD/backups":/backup \
alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite
```
> 也可以用 SQLite 官方的在线备份 API,**不用停服务、不用手工 checkpoint**:
> ```bash
> docker compose exec -T portal python -c "
> import sqlite3
> s = sqlite3.connect('/app/data/usage.sqlite'); d = sqlite3.connect('/tmp/b.sqlite')
> s.backup(d); d.close(); s.close()"
> docker compose cp portal:/tmp/b.sqlite ./backups/usage-$(date +%F).sqlite
> docker compose exec -T portal rm -f /tmp/b.sqlite
> ```
**方式 C:逻辑导出(跨版本最安全,可读性最好)** **方式 C:逻辑导出(跨版本最安全,可读性最好)**
```bash ```bash
@@ -643,11 +802,53 @@ docker compose exec portal python manage.py export-csv /app/data/exports
docker compose cp portal:/app/data/exports/. ./backups/exports/ docker compose cp portal:/app/data/exports/. ./backups/exports/
``` ```
建议:方式 B 每天跑(可用宿主机 cron)、方式 C 每季度跑一次、方式 A 在**升级前**跑。 建议:**方式 A 日常自动跑**(并定期把 zip 下载到容器之外/异地)、
裸机部署把上面命令里的 `docker compose exec portal` 去掉即可,路径换成相对的 `data/`。 方式 B 在**升级前**跑、方式 C 每季度跑一次。
裸机部署把上面命令里的 `docker compose exec portal` 去掉即可。
> ⚠️ **备份留在同一台机器上,只防「改错了」,不防「机器没了」**。
> 内置备份负责「本机有历史版本可回滚」,**异地那一份要你自己安排**
> (同步 `backups/` 到 NAS / 对象存储 / 另一台机器)。
### 8.3 恢复 ### 8.3 恢复
**方式 A:用内置恢复(推荐)**
页面:「备份管理」→ 找到那一份 → 点「恢复」→ 先弹一次确认(是否连 `instance.json`
一起回滚)→ 再弹一次最终确认。
```bash
# 命令行:不加 --yes 只打印「将要发生什么」,不会动数据
docker compose exec portal python manage.py restore usage-20260916-151043.zip
docker compose exec portal python manage.py restore usage-20260916-151043.zip --yes
```
它做的事,按顺序:
1. **先校验**归档(格式版本 / 条目齐全 / `PRAGMA integrity_check` / 库结构版本不高过当前程序)。
验不过就**一个字节都不动**。
2. **再给当前库自动打一份 `pre-restore` 快照**(`trigger=pre-restore`,在备份列表里能看到)。
恢复错了你还能回去 —— 这是恢复链路的兜底。
3. 解包 → 在**临时库**上先把结构迁移到当前版本 → 在一个 `BEGIN IMMEDIATE` 事务里
**整表替换** `users` / `settings` / `usage_records` / `collect_runs` / `audit_log` / `captchas`。
4. 把归档里的 `instance.json` 覆盖回来(原文件先另存 `.pre-restore-<时间戳>`)。
5. 让**所有账号的会话立即失效**(`session_ver` 全体 +1)—— 恢复是全局性事件,
旧会话描述的账号与权限可能已经被整个换掉了。
> **为什么是「整表替换」而不是「换文件」**:换文件需要停机、且要求没人持有文件句柄
> (Windows 上会直接失败);整表替换在一个事务里完成,中途出错就是一次回滚,
> 而且**老版本的备份(`user_version` 2 / 3)也能恢复** —— 迁移在临时库里先做完。
> 另外它按**列名交集**搬数据,不是 `SELECT *`:老库的列是 `ALTER` 追加的,
> 顺序与新库建表语句不一定一致,`SELECT *` 会**静默错位**(字段整体串位而值都「合法」)。
不想连密钥一起回滚(例如只想把数据退回去、保留本机当前的 `cookie_key`):
```bash
docker compose exec portal python manage.py restore <文件名> --yes --no-instance
```
**方式 B:整体换卷(容器起不来时的兜底)**
```bash ```bash
docker compose down docker compose down
@@ -665,9 +866,6 @@ docker compose exec portal python manage.py stats # 核对条数与积分
> **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。 > **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。
> 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。 > 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。
>
> 只恢复了库、没恢复 `instance.json` 的话,所有账号的 Cookie 都会显示「无法解密」,
> 各自重新粘贴一次即可 —— 历史用量数据不受影响。
### 8.4 迁移:从绑定挂载换到命名卷 ### 8.4 迁移:从绑定挂载换到命名卷
@@ -745,8 +943,9 @@ sudo systemctl restart workbuddy-portal
|---|---|---|---| |---|---|---|---|
| **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 | | **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 |
| **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 | | **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 |
| **1.3.0 → 1.4.0**(备份 + 对外加固) | 3 → 4 | 只做两件事:`users` 加一列 `session_ver`(非空、默认 0)、建 `backups` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 |
两次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: 三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
```bash ```bash
docker compose logs portal | grep -iE "迁移|migrat|提升|promote" docker compose logs portal | grep -iE "迁移|migrat|提升|promote"
@@ -764,10 +963,10 @@ docker compose exec portal python manage.py stats # 各账号条数与积分
| 频率 | 做什么 | | 频率 | 做什么 |
|---|---| |---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 | | 每天 | 打开「概览」看「采集健康」;确认今天有采集记录;确认「备份管理」里今天/昨天有一份 `auto` 备份 |
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**) | | 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**);`docker stats --no-stream` 看一眼内存有没有贴着 512m 上限 |
| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 | | 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);**把最新归档下载到容器之外**;跑一次备份恢复演练(见下) |
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 | | 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用与 `wb_backups` 卷的体积 |
一键体检: 一键体检:
@@ -775,10 +974,30 @@ docker compose exec portal python manage.py stats # 各账号条数与积分
docker compose exec portal python manage.py status # 调度与最近采集 docker compose exec portal python manage.py status # 调度与最近采集
docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细 docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细
docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态
docker compose exec portal python manage.py backups # 备份份数 / 占用 / 来源 / 下次自动备份
docker stats --no-stream workbuddy-portal # 实际 CPU / 内存占用
``` ```
磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3。 **备份恢复演练(每月一次,五分钟)**
增长慢,但**年度归档后记得对旧数据做取舍**(本项目不做自动清理,数据只增不减)。
不演练的备份等于没有备份 —— 只有在真的恢复过一次之后,你才知道那份 zip 是好的:
```bash
# 1) 随便挑一份归档,先只看它校验是否通过(不加 --yes,不会动数据)
docker compose exec portal python manage.py restore <文件名>
# 2) 真要演练恢复:确认当前库条数 → 恢复 → 再确认条数一致
docker compose exec portal python manage.py stats | head -3
docker compose exec portal python manage.py restore <文件名> --yes
docker compose exec portal python manage.py stats | head -3
```
> 恢复是**幂等且可回退**的:它会在动手前自动给你当前库打一份 `pre-restore` 快照,
> 恢复错了就再恢复回那一份。演练完记得 `backups --prune --keep N` 把多出来的清掉。
磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3,
每份归档约为数据库体积的 1/5(zip 压缩后)。增长慢,但**年度归档后记得对旧数据做取舍**
(本项目不做自动清理,数据只增不减;备份会按 `backup_keep` 自动清理)。
### 自检脚本(开发者向) ### 自检脚本(开发者向)
@@ -837,9 +1056,11 @@ docker compose up -d # 环境变量,必须 up -d,restart 不生效
|---|---| |---|---|
| `/logs`、`/logs/tail` | 403(导航里不显示) | | `/logs`、`/logs/tail` | 403(导航里不显示) |
| `/users`、`/api/users` | 403(导航里不显示) | | `/users`、`/api/users` | 403(导航里不显示) |
| `/backups`、`/api/backups*`(含**下载**与**恢复**) | 403(导航里不显示) |
| 任务管理页的调度表单 | 只读(时刻由管理员统一设定) | | 任务管理页的调度表单 | 只读(时刻由管理员统一设定) |
| 配置管理页的采集参数 | 只读 | | 配置管理页的采集参数 | 只读 |
| 本人的 Cookie / User-Agent | **可读写**(唯一可改的配置) | | 本人的 Cookie / User-Agent | **可读写**(唯一可改的配置) |
| 「个人中心 → 导出我的全部数据」 | **可用**(只含本人数据,**不含 Cookie 明文**) |
改权限:`docker compose exec portal python manage.py passwd alice 密码 --role admin`。 改权限:`docker compose exec portal python manage.py passwd alice 密码 --role admin`。
@@ -934,6 +1155,50 @@ docker compose exec portal date # 应该输出 CST / +0800
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
``` ```
> 注意这只关调度。手动采集、`manage.py collect`、**自动备份**都还在跑
> (自动备份由调度线程触发,所以关掉调度后自动备份也停了 ——
> 停调度期间请手动点几次「立即备份」)。
### 11.10 拿不到管理员初始口令
**症状**:第一次 `docker compose up -d` 之后想登录,不知道密码;日志里也找不到那行提示。
原因通常是**没设 `WB_ADMIN_PASSWORD`**(v1.4.0 起没有默认口令),而提示只在
「首次初始化那一刻」打印一次。依次试:
```bash
# 1) 翻日志(默认只给最近若干行,容器重启多轮后提示可能已被挤出,加 --tail 或直接 grep)
docker compose logs portal | grep -A6 '管理员初始口令'
docker compose logs --tail=2000 portal | grep -A6 '口令'
# 2) 如果管理员早就建好了,那行不会再出现 —— 直接重置:
docker compose exec portal python manage.py passwd admin '一个新的强密码'
# 3) 或者看数据库里到底有没有账号
docker compose exec portal python manage.py users
```
> **口令找不回来是有意的**:库里只有 PBKDF2 散列,明文只在生成时打印那一次、
> 不落盘。所以别指望「再查一次」——重置是正道。
> 下次部署记得在 `.env` 里显式写 `WB_ADMIN_PASSWORD`。
>
> 如果 `manage.py users` 显示**一个账号都没有**,说明初始化那步失败了,
> 去 `docker compose logs portal | grep -iE "init|error|traceback"` 看真实原因
> (最常见是数据卷权限,见 [4.5](#45-数据落点命名卷不是绑定挂载))。
### 11.11 备份相关
| 症状 | 原因 / 处理 |
|---|---|
| `/app/backups` 里是空的,但「备份管理」页说有 N 份 | 索引与实际磁盘不一致。页面每次打开都会 `sync_index()` 双向对齐,刷新一次即可;命令行用 `manage.py backups` 也会重建索引 |
| 列表里某一份标着「文件已不存在」 | 被手工删过(或恢复前的临时目录被清)。索引**故意保留这一行**,是为了留下「这里曾有过一份」的痕迹;用 `backups --prune` 或页面上的删除把它清掉 |
| 备份失败,日志里没有明显报错 | 自动备份的失败会记 `log.error`,看 `docker compose logs portal \| grep -i 备份`。常见原因是磁盘满或卷权限 |
| 恢复时提示「有采集任务正在运行」 | 设计如此:恢复要持采集锁。等当前采集结束(`manage.py status` 看互斥锁),或先停调度 |
| 恢复后所有人都被踢下线 | **设计如此**。恢复会让全体 `session_ver` +1 —— 归档里的会话版本与旧 Cookie 可能恰好相等,那样旧会话会带着「已被替换掉的账号与权限」继续用 |
| 恢复后所有 Cookie 显示「无法解密」 | 恢复时选了「不恢复 instance.json」,而两边的 `cookie_key` 不同。重新走一次恢复并带上 `instance.json`(默认会带),或让各账号重填 Cookie |
| 升级前想留一份最完整的 | 用 [8.2](#82-备份) 的方式 B(整体打包命名卷),它连 `exports/` 与 `instance.json` 一起包 |
| 想改保留份数 / 关掉自动备份 | 「备份管理 → 自动备份设置」里的 `backup_enabled` / `backup_interval_hours` / `backup_keep`(实例级,仅管理员) |
--- ---
## 十二、配置项速查 ## 十二、配置项速查
@@ -943,6 +1208,7 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。 表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。
**v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。 **v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。
其余全部是**实例级**(`GLOBAL_KEYS`,v1.4.0 起共 **23** 个),只有管理员能改。
写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。 写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。
| 键 | 默认 | 作用域 | 谁能改 | 说明 | | 键 | 默认 | 作用域 | 谁能改 | 说明 |
@@ -959,6 +1225,12 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `schedule_times` | `09:00,17:00` | 实例级 | 仅管理员 | 每日时刻,逗号分隔,本地时区 | | `schedule_times` | `09:00,17:00` | 实例级 | 仅管理员 | 每日时刻,逗号分隔,本地时区 |
| `catch_up` | `1` | 实例级 | 仅管理员 | 启动补跑开关 | | `catch_up` | `1` | 实例级 | 仅管理员 | 启动补跑开关 |
| `catch_up_grace_hours` | `12` | 实例级 | 仅管理员 | 补跑宽限期(1~168 小时) | | `catch_up_grace_hours` | `12` | 实例级 | 仅管理员 | 补跑宽限期(1~168 小时) |
| `max_schedule_slots_per_day` | `6` | 实例级 | 仅管理员 | **每日调度时刻数上限**(1~12,代码硬顶 12)。挡住「填 200 个时刻」 |
| `collect_min_interval_seconds` | `60` | 实例级 | 仅管理员 | **同一账号两次手动采集的最小间隔**(0~3600 秒);期间再点会 `429` |
| `collect_max_range_days` | `31` | 实例级 | 仅管理员 | **单次采集的最长跨度**(1~31 天,代码硬顶 31 = 1 个月)。改大也突破不了硬顶 |
| `backup_enabled` | `1` | 实例级 | 仅管理员 | 是否开启自动备份 |
| `backup_interval_hours` | `24` | 实例级 | 仅管理员 | 备份周期(1~720 小时) |
| `backup_keep` | `7` | 实例级 | 仅管理员 | 保留最近几份,超出的删最旧的(1~100) |
| `page_size` | `200` | 实例级 | 仅管理员 | 采集单页条数(20~1000) | | `page_size` | `200` | 实例级 | 仅管理员 | 采集单页条数(20~1000) |
| `rewind_minutes` | `2` | 实例级 | 仅管理员 | 断点回退分钟数(0~120) | | `rewind_minutes` | `2` | 实例级 | 仅管理员 | 断点回退分钟数(0~120) |
| `drift_tolerance_minutes` | `5` | 实例级 | 仅管理员 | 云端时间漂移告警阈值(0~720) | | `drift_tolerance_minutes` | `5` | 实例级 | 仅管理员 | 云端时间漂移告警阈值(0~720) |
@@ -994,13 +1266,19 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址(仅 compose 用) | | `WB_BIND` | `0.0.0.0` | 宿主机绑定地址(仅 compose 用) |
| `WB_HOST` / `WB_PORT` | `0.0.0.0` / `8848` | 容器内监听地址 / 端口 | | `WB_HOST` / `WB_PORT` | `0.0.0.0` / `8848` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | `/app/data` / `/app/logs` / `<数据目录>/usage.sqlite` | 路径覆盖 | | `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | `/app/data` / `/app/logs` / `<数据目录>/usage.sqlite` | 路径覆盖 |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) | | `WB_BACKUP_DIR` | `/app/backups` | 备份归档落点(**不要放进 `data/`**,见 [8.1](#81-备份什么)) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程 | | `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`)。**同时是下发 HSTS 的开关之一** |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**) | | `WB_TRUST_PROXY` | `0` | `1` = 信任 `X-Forwarded-For`(取**最右侧**合法 IP)。**直接暴露公网必须留 0**,见 [第六节](#六反向代理与-https) |
| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转(POST 不跳) |
| `WB_ACCESS_LOG` | `1` | 记访问日志到 `logs/app.log`(跳过静态资源与验证码图) |
| `WB_THREADS` | `4`(compose)/ `8`(代码默认) | waitress 线程数 = 单实例并发处理慢请求的上限 |
| `WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` | `1.0` / `512m` / `256` | 容器资源上限(仅 compose 用,见 [4.4](#44-env-主要变量)) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(**自动备份也会一起停**) |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**)。留空则随机生成、只在启动日志打印一次 |
| `WB_IMPORT_CREDS` | `0` | 启动时从挂载的编辑器配置导入 Cookie | | `WB_IMPORT_CREDS` | `0` | 启动时从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时导入指定路径的官网 xlsx | | `WB_IMPORT_XLSX` | 空 | 启动时导入指定路径的官网 xlsx |
| `WB_IMAGE` | Gitea 地址 | 镜像名(仅 compose 用) | | `WB_IMAGE` | Gitea 地址 | 镜像名(仅 compose 用) |
| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` | `./data` / `./logs` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) | | `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` / `WB_HOST_BACKUP_DIR` | `./data` / `./logs` / `./backups` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) |
### 12.3 `manage.py` 子命令速查 ### 12.3 `manage.py` 子命令速查
@@ -1019,5 +1297,8 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `passwd USER [PASSWORD] [--role admin\|user] [--activate]` | 重置 / 创建账号 | | `passwd USER [PASSWORD] [--role admin\|user] [--activate]` | 重置 / 创建账号 |
| `status` | 各账号的调度与最近采集状态 | | `status` | 各账号的调度与最近采集状态 |
| `vacuum` | 整理数据库(checkpoint + VACUUM) | | `vacuum` | 整理数据库(checkpoint + VACUUM) |
| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → zip),并按保留份数清理最旧的 |
| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间);`--prune` 顺便清理 |
| `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` |
Docker 部署下前面加 `docker compose exec portal`。 Docker 部署下前面加 `docker compose exec portal`。
+102 -9
查看文件
@@ -139,7 +139,8 @@
| 查看概览、用量大屏 | ✅(只有你自己的数据) | | 查看概览、用量大屏 | ✅(只有你自己的数据) |
| 查看 / 搜索数据明细、展开 Prompt | ✅(只有你自己的记录) | | 查看 / 搜索数据明细、展开 Prompt | ✅(只有你自己的记录) |
| 导出 CSV(当前筛选条件) | ✅(只有你自己的记录) | | 导出 CSV(当前筛选条件) | ✅(只有你自己的记录) |
| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的) | | **导出我的全部数据**(zip:记录 / 采集历史 / 审计 / 账号与配置) | ✅ **不含 Cookie 明文**,见 [10](#十个人中心) |
| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的,且有频率与跨度限制,见 [8.2](#82-手动采集你可以用)) |
| 「补全 Prompt」「导出我的 CSV」 | ✅(只动你自己的) | | 「补全 Prompt」「导出我的 CSV」 | ✅(只动你自己的) |
| 查看采集运行历史 | ✅(只有你自己的) | | 查看采集运行历史 | ✅(只有你自己的) |
| **设置定时任务频率 / 开关 / 补跑策略** | ❌ **只读** | | **设置定时任务频率 / 开关 / 补跑策略** | ❌ **只读** |
@@ -147,6 +148,7 @@
| **查看日志管理页 / 应用日志** | ❌ 403 | | **查看日志管理页 / 应用日志** | ❌ 403 |
| **改实例级设置**(接口地址、开放注册、验证码策略) | ❌ | | **改实例级设置**(接口地址、开放注册、验证码策略) | ❌ |
| **用户管理**(建号 / 停用 / 删号 / 改权限) | ❌ 403 | | **用户管理**(建号 / 停用 / 删号 / 改权限) | ❌ 403 |
| **备份管理**:下载数据库归档、从备份恢复 | ❌ 403(恢复等于对全库数据有完整读写权,只给管理员) |
| **整理数据库**(`VACUUM`,整库操作) | ❌ | | **整理数据库**(`VACUUM`,整库操作) | ❌ |
> **为什么调度不给你改**:采集策略是**整机一套**的(一台部署一个调度时刻表, > **为什么调度不给你改**:采集策略是**整机一套**的(一台部署一个调度时刻表,
@@ -160,9 +162,9 @@
| 是「你的」 | 是「共用的 / 管理员管」 | | 是「你的」 | 是「共用的 / 管理员管」 |
|---|---| |---|---|
| Cookie 与 User-Agent | 接口基址与路径 | | Cookie 与 User-Agent | 接口基址与路径 |
| 用量记录、采集历史、导出的 CSV | 采集调度时刻表与全部采集参数 | | 用量记录、采集历史、导出的 CSV / zip | 采集调度时刻表与全部采集参数 |
| 个人资料、登录密码 | 是否开放自助注册、注册限额、验证码策略 | | 个人资料、登录密码 | 是否开放自助注册、注册限额、验证码策略 |
| — | 数据库文件本身、应用日志 | | — | 数据库文件本身、应用日志、**数据库备份归档** |
三条值得记牢: 三条值得记牢:
@@ -172,6 +174,9 @@
**记录条数 / 积分合计 / 最后登录时间与 IP** —— 只给「有多少」,不给「是什么」。 **记录条数 / 积分合计 / 最后登录时间与 IP** —— 只给「有多少」,不给「是什么」。
- **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号), - **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号),
所以每个账号都必须各自配一次 Cookie。 所以每个账号都必须各自配一次 Cookie。
- **但备份是管理员的能力**:管理员能下载整个数据库的归档、也能用归档覆盖回来。
这是**运维必需**(不然数据丢了没人能救),代价是管理员对全库数据有完整的读写权。
介意这一点的话,就自己用「导出我的全部数据」留一份,见 [10](#十个人中心)。
--- ---
@@ -371,9 +376,17 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
重扫**不会产生重复** —— 主键去重,已存在的记录按「更早的本地时间」保留。 重扫**不会产生重复** —— 主键去重,已存在的记录按「更早的本地时间」保留。
> 这两个动作**只采集你自己的**数据,不会碰到别人的。 > 这两个动作**只采集你自己的**数据,不会碰到别人的。
>
> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此 **三条限制**(v1.4.0 起,页面上会写出来):
> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。
| 限制 | 表现 | 为什么 |
|---|---|---|
| **有任务在跑时不能再发起** | 点「立即采集」返回「**正在采集**,请等它结束」 | SQLite 是单写者,并发采集只会互相拖慢,还可能把云端接口打得太密 |
| **两次手动采集之间有最小间隔**(默认 60 秒) | 返回「操作太频繁,请 N 秒后再试」 | 防止反复点按钮把请求刷爆 |
| **单次最长跨度 31 天**(约 1 个月) | 起始日期填得太早会被自动收窄,运行日志里出现一行 `[warn] 请求跨度超过上限 N 天,已自动收窄起点` | 避免一次拉取过长周期的数据(云端要分很多页,慢且容易失败) |
> 想补更久以前的数据?**分几次做**:比如先补 1 月,再补 2 月。
> 跨度过长的请求不是被拒就是跑到一半超时,分段的成功率反而更高。
### 8.3 运行历史 ### 8.3 运行历史
@@ -459,14 +472,37 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
|---|---| |---|---|
| 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 | | 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 |
| 修改资料 | 改显示名、邮箱(**用户名只读**) | | 修改资料 | 改显示名、邮箱(**用户名只读**) |
| 修改登录密码 | 需要原密码;改完当前会话仍然有效 | | 修改登录密码 | 需要原密码。**改成功后其他设备上的登录会立刻失效**(本机这次会话保留,不然改完立刻被自己踢下线) |
| 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻(只读) | | 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻(只读) |
| **导出我的全部数据** | 下载一个 zip,把**属于你的**东西一次带走(见下) |
顶栏右侧会有一个 **「普通账号」** 小标签(管理员则是「管理员」)—— 顶栏右侧会有一个 **「普通账号」** 小标签(管理员则是「管理员」)——
不确定自己是什么权限时看一眼这里。 不确定自己是什么权限时看一眼这里。
> 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。 > 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。
#### 导出我的全部数据
页头有一个「**导出我的全部数据**」按钮(也有单独一张卡片说明它在打包什么)。
点下去会下载一个 zip,里面是:
| 文件 | 内容 |
|---|---|
| `使用记录.csv` | 你**全部**的用量明细(不受页面筛选条件影响) |
| `采集历史.csv` | 你的采集运行记录 |
| `操作审计.csv` | **你自己**的操作审计(别人的看不到) |
| `我的账号与配置.json` | 用户名 / 显示名 / 邮箱 / 角色 / 状态 / 本人的采集参数(只读项也在内) |
| `说明.txt` | 各文件的口径说明 |
> **里面没有 Cookie 明文。** 导出自己的数据不等于把凭证交出去 —— 凭证是密文入库的,
> 导出接口不碰它。需要迁移凭证就在新环境重新粘一次。
>
> 这个动作有 **10 秒的最小间隔**(防止连点生成一堆大文件)。数据量很大时
> 服务端是**边生成边下发**的,浏览器会先等一下再开始下载。
> 想留在系统里、由运维统一保管的那种备份(含所有人的数据),那是「备份管理」页的事,
> 只有管理员能做。你的 zip 只包含你自己。
--- ---
## 十一、管理员专属功能 ## 十一、管理员专属功能
@@ -479,14 +515,20 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
| 项 | 默认 | 说明 | | 项 | 默认 | 说明 |
|---|---|---| |---|---|---|
| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑 | | 启用调度 | 开 | 总开关。关掉后只有手动采集会跑(**注意:自动备份也一起停**,见 [11.5](#115-备份管理页)) |
| 每日时刻 | `09:00,17:00` | 逗号分隔的本地时刻。**保存即生效,不用重启** | | 每日时刻 | `09:00,17:00` | 逗号分隔的本地时刻。**保存即生效,不用重启** |
| 每日时刻上限 | 6 | 最多允许几个时刻(上限 12,代码硬顶)。时刻数直接决定采集频次 |
| 启动补跑 | 开 | 启动时把今天已错过、且还在宽限期内的时刻补采一次 | | 启动补跑 | 开 | 启动时把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 12 小时 | 超过多少小时就不补了 | | 补跑宽限期 | 12 小时 | 超过多少小时就不补了 |
| 采集最小间隔 | 60 秒 | 同一账号两次**手动**采集之间的最小间隔 |
| 单次最长跨度 | 31 天 | 一次采集最多覆盖多少天(硬顶 31 = 1 个月,改大也没用) |
| 采集参数 | 见 9.2 | 分页 / 回退 / 超时 / 截断 / 证书校验等 | | 采集参数 | 见 9.2 | 分页 / 回退 / 超时 / 截断 / 证书校验等 |
> ⚠️ **改 `schedule_times` 会清理槽位簿记**:系统只清掉「不再存在的时刻」对应的 > ⚠️ **改 `schedule_times` 会清理槽位簿记**:系统只清掉「不再存在的时刻」对应的
> 槽位标记(所有账号一起清),**不做全清** —— 全清会让全部账号在宽限期内一起重采。 > 槽位标记(所有账号一起清),**不做全清** —— 全清会让全部账号在宽限期内一起重采。
>
> ⚠️ **把「每日时刻上限」调小**时,超出上限的那几个时刻会被一并清掉(这是有意的:
> 上限本身就是刹车,留着超限的配置只会让人以为它还生效)。
### 11.2 实例级设置 ### 11.2 实例级设置
@@ -506,6 +548,9 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
> 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删 —— > 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删 ——
> 所以它不会随会话 Cookie 泄漏出去。 > 所以它不会随会话 Cookie 泄漏出去。
> **自动备份的三个设置**(开关 / 周期 / 保留份数)不在这里,它们有自己的页面,
> 见 [11.5 备份管理页](#115-备份管理页)。
### 11.3 日志管理页 ### 11.3 日志管理页
![日志管理页](images/05-logs.png) ![日志管理页](images/05-logs.png)
@@ -535,7 +580,7 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
| 改显示名 | 行内直接改,点该行「保存」生效 | | 改显示名 | 行内直接改,点该行「保存」生效 |
| 改权限 | 管理员 ↔ 普通 | | 改权限 | 管理员 ↔ 普通 |
| 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) | | 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) |
| 改密码 | 给忘了密码的同事重置 | | 改密码 | 给忘了密码的同事重置。**重置后他在所有设备上的登录立刻失效**,需重新登录 |
| 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 | | 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 |
列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**: 列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**:
@@ -553,6 +598,54 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
> **给同事开账号时默认选「普通」**:管理员是能停用别人账号的角色,没必要扩大。 > **给同事开账号时默认选「普通」**:管理员是能停用别人账号的角色,没必要扩大。
### 11.5 备份管理页
![备份管理页](images/11-backups.png)
> 这一页**只有管理员能看到**(普通账号导航里没有,直接敲 `/backups` 返回 403)。
> 理由很实际:**能下载或恢复整个数据库的人,等于对全库数据有完整的读写权。**
**顶部四张 KPI**:现有份数 / 占用空间 / 自动备份开关 / 上次与下次自动备份时间。
**自动备份设置**(实例级,改完即生效):
| 设置 | 默认 | 说明 |
|---|---|---|
| 开启自动备份 | 开 | 关掉后调度线程不再打新快照(已有的不会删) |
| 备份周期 | 24 小时 | 1 ~ 720 小时。到期就打一份,**有采集在跑时跳过**,等下轮 |
| 保留份数 | 7 | 超出的**按时间删最旧的**。只按磁盘上**真实存在**的文件算份数 |
**备份列表**:每行给出文件名、大小、记录条数、积分、**来源**(自动 / 手动 / 命令行 /
恢复前的自动快照)、生成时间,以及三个按钮:
| 按钮 | 做什么 |
|---|---|
| 下载 | 把 zip 存到本地。**归档里含 `instance.json`,也就是全库的加密主密钥**,所以它本身就是最高机密 —— 别放进公开网盘、别随手发人 |
| 恢复 | 把这份归档灌回数据库。**会先弹两次确认**(第二次专门问「是否连密钥一起回滚」) |
| 删除 | 删掉这一份(文件 + 索引行) |
#### 「恢复」究竟做了什么(要点)
1. **先校验**这份归档能不能用;验不过就**一个字节都不动**。
2. **自动给当前库打一份快照**(来源标 `pre-restore`)—— 恢复错了还能回去。
3. **整表替换**记录 / 配置 / 账号等数据(在一个事务里,中途失败自动回滚)。
4. 可选把归档里的密钥文件一起覆盖回来。
5. **让所有人的登录立即失效** —— 恢复是全局性事件,旧会话描述的账号与权限
可能已经整个被换掉了。
> **几个常见疑问**
>
> - **归档是怎么来的?** 不是 `cp` 出来的。走的是 SQLite 官方在线备份 API,
> 按页复制并持有读事务 —— **采集正在写的时候拿到的也是一个完整一致的库**。
> 直接拷文件可能缺最近一段数据,而且**不报错**。
> - **为什么归档里要带密钥?** 不带的话,恢复出来的库里所有账号的 Cookie 都会
> 变成「无法解密」,等于把大家的凭证弄丢。所以默认带上;不想带就在恢复时选「否」。
> - **备份留在同一台机器上算备份吗?** 只算一半 —— 它防的是「改错了」,
> 防不了「机器没了」。**请定期点「下载」把归档拿到别的地方去**,
> 异地那一份是这套机制替不了你的部分。
> - **磁盘占用怎么办?** 「保留份数」会自动清理。也可以手动点删除,
> 或跑 `python manage.py backups --prune --keep 5`。
--- ---
## 十二、信息安全与隐私安全 ## 十二、信息安全与隐私安全
二进制
查看文件
二进制文件未显示。

之后

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

+152 -3
查看文件
@@ -28,6 +28,9 @@
python manage.py passwd <用户名> --role admin 新建或提权为管理员 python manage.py passwd <用户名> --role admin 新建或提权为管理员
python manage.py status 查看各账号的调度与最近采集状态 python manage.py status 查看各账号的调度与最近采集状态
python manage.py vacuum 整理数据库(checkpoint + VACUUM) 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 argparse
import os import os
@@ -35,7 +38,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 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 from workbuddy_portal import security # noqa: E402
@@ -102,6 +105,19 @@ def cmd_init(args):
_p(" 账号数:%d" % db.user_count(conn)) _p(" 账号数:%d" % db.user_count(conn))
_p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"])) _p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 操作账号:%s" % args.user) _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) st = db.secret_state(conn, "cookie", uid)
if st["broken"]: if st["broken"]:
_p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴") _p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴")
@@ -119,8 +135,11 @@ def cmd_serve(args):
return return
try: try:
from waitress import serve from waitress import serve
_p("生产模式(waitress)监听 http://%s:%d" % (host, port)) # 线程数是资源上限的一部分(见 config.THREADS 的注释):
serve(app, host=host, port=port, threads=8, ident="workbuddy-portal") # 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复)。
_p("生产模式(waitress)监听 http://%s:%d,线程数 %d"
% (host, port, config.THREADS))
serve(app, host=host, port=port, threads=config.THREADS, ident="workbuddy-portal")
except ImportError: except ImportError:
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)") _p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
app.run(host=host, port=port, threaded=True) app.run(host=host, port=port, threaded=True)
@@ -474,6 +493,120 @@ def cmd_vacuum(args):
return 0 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): def _human(n):
for unit in ("B", "KB", "MB", "GB"): for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB": if n < 1024 or unit == "GB":
@@ -552,6 +685,22 @@ def main():
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)") s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
s.set_defaults(func=cmd_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() args = ap.parse_args()
if not getattr(args, "func", None): if not getattr(args, "func", None):
ap.print_help() ap.print_help()
+4 -1
查看文件
@@ -47,6 +47,7 @@ CAPTURES = [
("06-users.png", "/users", "用户管理", True), ("06-users.png", "/users", "用户管理", True),
("07-dashboard.png", "/dashboard", "用量大屏", True), ("07-dashboard.png", "/dashboard", "用量大屏", True),
("10-profile.png", "/profile", "个人中心", True), ("10-profile.png", "/profile", "个人中心", True),
("11-backups.png", "/backups", "备份管理(仅管理员)", True),
] ]
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态, # v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
@@ -237,7 +238,9 @@ def main() -> int:
for name, path, label in USER_CAPTURES: for name, path, label in USER_CAPTURES:
shoot(name, path, label, pg=page2) shoot(name, path, label, pg=page2)
# 顺带把越权面再验一次:普通账号访问这些必须不是 200 # 顺带把越权面再验一次:普通账号访问这些必须不是 200
for probe in ("/logs", "/logs/tail", "/users"): # /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") r = page2.goto(a.base + probe, wait_until="domcontentloaded")
code = r.status if r else 0 code = r.status if r else 0
ok = code in (403, 401) ok = code in (403, 401)
+101
查看文件
@@ -97,6 +97,11 @@ def run() -> None:
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==") print("== 0. 构建应用 ==")
# 先把真实库迁到与代码同版本。init_db 是幂等的,顺带把迁移路径也验一遍。
# 少了这一步,「库还停在旧 schema」会被 current_user() 判成「未登录」,
# 现场表现是下面所有页面断言集体变 302 —— 看起来像产品坏了,
# 其实只是前置条件没满足。
db.init_db()
app = create_app(start_scheduler=False, do_init_db=False) app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()]) n_routes = len([r for r in app.url_map.iter_rules()])
@@ -105,6 +110,9 @@ def run() -> None:
# 真实库里的账号:管理员必须有,普通账号按需临时造 # 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect() 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'" admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone() " ORDER BY id LIMIT 1").fetchone()
if admin_row is None: if admin_row is None:
@@ -673,6 +681,99 @@ def run() -> None:
missing = sorted(c for c in used - css_classes - allow) missing = sorted(c for c in used - css_classes - allow)
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "") 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: def main() -> int:
print("工程目录:%s\n" % BASE) print("工程目录:%s\n" % BASE)
+1 -1
查看文件
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
from . import config, db, security from . import config, db, security
__version__ = "1.3.0" __version__ = "1.4.0"
PROJECT_NAME = config.PROJECT_NAME 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 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动, 5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移,
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。 笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。
强度说明(为什么要这么多花样)
------------------------------
字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。
在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上
尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。
纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。
反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 ——
验证码是「提高成本」而不是「杜绝」。
""" """
import hmac import hmac
import os import math
import random import random
import secrets import secrets
import struct import struct
@@ -140,54 +149,126 @@ class _Canvas:
return bytes(self.buf) 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): def render(code, width=150, height=56, scale=5, rng=None):
"""把验证码渲染成 PNG 字节串。 """把验证码渲染成 PNG 字节串。
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心, 字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 ——
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。 目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同,
从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。
""" """
rng = rng or random.SystemRandom() rng = rng or random.SystemRandom()
n = len(code) n = len(code)
gap = 7 gap = 9
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) text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap
y0 = max(3, (height - GLYPH_H * scale) // 2) 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)) c1 = tuple(rng.randint(236, 252) for _ in range(3))
cv = _Canvas(width, height, bg) 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) 干扰线(先画,压在字下面,不遮挡主体) # 1) 底层干扰线(先画,压在字下面)
for _ in range(4): for _ in range(3):
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1), cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
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): for i, ch in enumerate(code):
glyph = _FONT.get(ch) glyph = _FONT.get(ch)
if glyph is None: if glyph is None:
continue continue
color = tuple(rng.randint(20, 105) for _ in range(3)) # 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1) s = max(3, scale + rng.choice((-1, 0, 0, 1)))
gy = y0 + rng.randint(-2, 2) cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0)
for row, bits in enumerate(glyph): cy = y0 + rng.uniform(-3.0, 3.0)
for col, bit in enumerate(bits): color = tuple(rng.randint(15, 95) for _ in range(3))
if bit == "#": _draw_char(cv, glyph, cx, cy, s, color, rng)
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠 # 3) 前景噪点:破坏「按连通域找字符」的假设
for _ in range(46): for _ in range(70):
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1), 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 手段,但别太密,否则人也认不出 # 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出,
for _ in range(3): # 所以刻意控制成 2~3 条细线。
for _ in range(rng.randint(2, 3)):
y = rng.randint(2, height - 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))) tuple(rng.randint(120, 175) for _ in range(3)))
return encode_png(width, height, cv.bytes()) return encode_png(width, height, cv.bytes())
+28
查看文件
@@ -205,6 +205,22 @@ def record_count(conn, uid):
(uid or 0,)).fetchone()[0] (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): def last_ts(conn, uid):
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?", return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0] (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 end = to_dt or now
if start >= end: if start >= end:
start = end - timedelta(minutes=rewind) 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"), _log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
end.strftime("%Y-%m-%d %H:%M:%S"))) end.strftime("%Y-%m-%d %H:%M:%S")))
+118 -10
查看文件
@@ -28,6 +28,13 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
APP_LOG = os.path.join(LOG_DIR, "app.log") APP_LOG = os.path.join(LOG_DIR, "app.log")
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json") INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
# 备份落点。**刻意与 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 默认读这里)。 # 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置, # v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
# 后面两个保留以兼容「还没挪走」的部署。 # 后面两个保留以兼容「还没挪走」的部署。
@@ -58,6 +65,13 @@ DEFAULTS = {
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区) "schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位 "catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑 "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", "page_size": "200",
"rewind_minutes": "2", # 断点回退分钟数 "rewind_minutes": "2", # 断点回退分钟数
@@ -91,6 +105,12 @@ GLOBAL_KEYS = {
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length", "allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率) # 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours", "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/关 ssl_verify 都是越权)
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt", "page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
"verify_days", "timeout", "ssl_verify", "verify_days", "timeout", "ssl_verify",
@@ -155,18 +175,33 @@ NUM_SETTINGS = {
"catch_up_grace_hours": (1, 168, "小时"), "catch_up_grace_hours": (1, 168, "小时"),
"captcha_length": (4, 6, "个字符"), "captcha_length": (4, 6, "个字符"),
"register_max_per_ip": (1, 50, "个/天"), "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", "是", "启用") _TRUE = ("1", "true", "yes", "on", "是", "启用")
def normalize_setting(key, raw): def normalize_setting(key, raw, conn=None):
"""校验并规范化单个设置值。 """校验并规范化单个设置值。
返回 (value, error): 返回 (value, error):
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。 * value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。 * 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
* `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受
`max_schedule_slots_per_day` 约束),有连接时才查得到。
不传时只做代码层的硬顶校验,所以离线调用不会因此失败。
""" """
if key not in DEFAULTS: if key not in DEFAULTS:
return None, "未知配置项:%s" % key return None, "未知配置项:%s" % key
@@ -196,6 +231,19 @@ def normalize_setting(key, raw):
parsed = scheduler.parse_times(raw) parsed = scheduler.parse_times(raw)
if not parsed: if not parsed:
return None, "每日时刻格式不对,正确写法如 09:00,17:00" 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 return ",".join(parsed), None
if key == "captcha_policy": if key == "captcha_policy":
@@ -208,8 +256,14 @@ def normalize_setting(key, raw):
v = str(raw).strip() v = str(raw).strip()
if not v: if not v:
return None, "%s 不能为空" % key return None, "%s 不能为空" % key
if key == "api_base" and not v.startswith(("http://", "https://")): if key == "api_base":
return None, "接口基址需以 http:// 或 https:// 开头" 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 return v, None
if key == "cookie": if key == "cookie":
@@ -222,8 +276,57 @@ def normalize_setting(key, raw):
DEFAULT_HOST = "0.0.0.0" # 局域网可访问 DEFAULT_HOST = "0.0.0.0" # 局域网可访问
DEFAULT_PORT = 8848 DEFAULT_PORT = 8848
SESSION_HOURS = 12 SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数 MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁)
LOGIN_LOCK_MINUTES = 10 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 位,字母开头 USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
@@ -233,13 +336,13 @@ PASSWORD_MAX = 128
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。 # 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
PROFILE_EMAIL_MAX = 128 PROFILE_EMAIL_MAX = 128
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送, # 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。 # 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定,
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on") # 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
def ensure_dirs(): 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) os.makedirs(d, exist_ok=True)
@@ -265,6 +368,11 @@ def _instance_init(key, maker):
try: try:
with open(INSTANCE_FILE, "w", encoding="utf-8") as f: with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2) 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: except OSError:
pass # 只读文件系统时退化为「本次进程内有效」 pass # 只读文件系统时退化为「本次进程内有效」
return val return val
+76 -8
查看文件
@@ -26,13 +26,17 @@
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」), * `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
虽然时刻本身是实例级的 —— 这两个千万别一起改。 虽然时刻本身是实例级的 —— 这两个千万别一起改。
""" """
import logging
import os import os
import secrets
import sqlite3 import sqlite3
import threading import threading
from datetime import datetime from datetime import datetime
from . import config, crypto from . import config, crypto
log = logging.getLogger("wb.db")
_local = threading.local() _local = threading.local()
_init_lock = threading.Lock() _init_lock = threading.Lock()
_initialized = False _initialized = False
@@ -41,11 +45,22 @@ _initialized = False
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键) # 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明) # 2 -> 多用户布局(见 schema.sql 顶部说明)
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS) # 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
DB_SCHEMA_VERSION = 3 # 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
DB_SCHEMA_VERSION = 4
# 这些键即使个人作用域没有值,也**不**回落到实例级 # 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"} 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): class SecretUnreadable(Exception):
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。""" """密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
@@ -167,7 +182,8 @@ def _migrate(conn):
for col, ddl in (("email", "TEXT"), for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"), ("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"), ("register_ip", "TEXT"),
("last_login_ip", "TEXT")): ("last_login_ip", "TEXT"),
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
if col not in tusers: if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl)) conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col) done.append("users 增加 %s" % col)
@@ -264,6 +280,38 @@ def _promote_personal_to_global(conn):
return promoted, cleaned 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): def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。""" """建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized global _initialized
@@ -316,12 +364,7 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
if create_admin: if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0: if n == 0:
from .security import hash_password _create_first_admin(conn, admin_user, admin_password, ts)
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))
_initialized = True _initialized = True
return migrated return migrated
finally: finally:
@@ -458,6 +501,31 @@ def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] 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): 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)" conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
+11 -1
查看文件
@@ -26,7 +26,7 @@ import os
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from . import collect, db from . import backup, collect, db
log = logging.getLogger("wb.scheduler") log = logging.getLogger("wb.scheduler")
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存) SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
@@ -176,6 +176,16 @@ class Scheduler:
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e) log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
except Exception as e: except Exception as e:
log.error("账号 %s 采集失败:%s", u["username"], 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 return True
+23
查看文件
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT, email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled status TEXT NOT NULL DEFAULT 'active', -- active | disabled
session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效
created_at TEXT, created_at TEXT,
register_ip TEXT, -- 自助注册来源,用于每日限额 register_ip TEXT, -- 自助注册来源,用于每日限额
last_login_at 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); 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()` 会把凭证置空, `db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
所以「顺手把配置回传给前端」这类代码不可能把它带出去。 所以「顺手把配置回传给前端」这类代码不可能把它带出去。
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次 2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。 users.status 与 users.session_ver,不靠会话过期来兜底。
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住 3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 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 functools
import hmac import hmac
import ipaddress
import logging
import re import re
import secrets import secrets
import sqlite3
import time import time
from flask import (current_app, flash, g, jsonify, redirect, render_template, 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 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 的部署约束),重启清零可接受; # 单进程部署(见 README 的部署约束),重启清零可接受;
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。 # 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
_fails = {} # key -> [count, last_ts] _fails = {} # key -> [count, last_ts]
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆 _FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理 _FAILS_TTL = 3600 # 超过 1 小时无更新即清理
@@ -88,9 +140,76 @@ def fail_count(key):
return c[0] if c else 0 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=""): def auth_locked(ip, username=""):
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。""" """还需等待的秒数(0 = 放行)。"""
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username))) 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=""): def note_auth_fail(ip, username=""):
@@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""):
def clear_auth_fail(ip, username=""): def clear_auth_fail(ip, username=""):
"""登录成功后清掉两个维度的计数。
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
之后,主人仍会被自己之前那几次的退避拖住。
"""
clear_fail(_ip_key(ip)) clear_fail(_ip_key(ip))
if username: if username:
clear_fail(_user_key(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) _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]")) (r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
if classes < 2: if classes < 2:
return "密码需包含大写字母、小写字母、数字、符号中的至少两类" return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
if new.lower() in config.WEAK_PASSWORDS:
return "这个密码在常见弱口令字典里,请换一个"
if new2 is not None and new2 != new: if new2 is not None and new2 != new:
return "两次输入的新密码不一致" return "两次输入的新密码不一致"
if username and new.lower() == str(username).lower(): if username and new.lower() == str(username).lower():
@@ -182,8 +334,12 @@ def login_ok(conn, username, password):
def current_user(): def current_user():
"""当前登录用户(dict)或 None。 """当前登录用户(dict)或 None。
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效, 每个请求回查一次 users 表,两道校验:
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。 * `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
旧会话照样有效到 12 小时之后。
结果缓存在 flask.g 里,一次请求只查一次。
""" """
if "wb_user" in g: if "wb_user" in g:
return g.wb_user return g.wb_user
@@ -192,12 +348,24 @@ def current_user():
if uid: if uid:
try: try:
row = db.get_db().execute( row = db.get_db().execute(
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?", "SELECT id,username,display_name,is_admin,status,session_ver"
(uid,)).fetchone() " 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 (无请求上下文等) except Exception: # noqa: BLE001 (无请求上下文等)
row = None row = None
if row is None or (row["status"] or "active") != "active": if row is None or (row["status"] or "active") != "active":
session.clear() session.clear()
elif session.get("sv", 0) != db.session_ver_of(row):
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
session.clear()
else: else:
user = {"id": row["id"], "username": row["username"], user = {"id": row["id"], "username": row["username"],
"display_name": row["display_name"] or row["username"], "display_name": row["display_name"] or row["username"],
@@ -219,12 +387,16 @@ def login_session(user):
`session.clear()` 是必须的:既清掉前一次的残留, `session.clear()` 是必须的:既清掉前一次的残留,
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。 也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
这一版会话会在下一个请求就被判为过期。
""" """
session.clear() session.clear()
session["uid"] = user["id"] session["uid"] = user["id"]
session["uname"] = user["username"] session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"] session["dname"] = user["display_name"] or user["username"]
session["adm"] = 1 if user["is_admin"] else 0 session["adm"] = 1 if user["is_admin"] else 0
session["sv"] = db.session_ver_of(user)
session["login_at"] = db.now_str() session["login_at"] = db.now_str()
session.permanent = True session.permanent = True
@@ -401,19 +573,54 @@ def apply_security_headers(resp):
resp.headers.setdefault("Referrer-Policy", "same-origin") resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP) resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") 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"): if request.path.startswith("/api/") or request.path.startswith("/captcha"):
resp.headers.setdefault("Cache-Control", "no-store") resp.headers.setdefault("Cache-Control", "no-store")
return resp 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): def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user app.jinja_env.globals["current_user"] = current_user
@app.before_request @app.before_request
def _guard(): 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() return check_csrf()
@app.after_request @app.after_request
def _headers(resp): def _headers(resp):
_access_log(resp, getattr(g, "wb_t0", time.time()))
return apply_security_headers(resp) return apply_security_headers(resp)
+222 -16
查看文件
@@ -23,9 +23,9 @@
import os import os
from datetime import datetime 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 from ..security import admin_required, current_user, is_admin, login_required
bp = Blueprint("api", __name__, url_prefix="/api") bp = Blueprint("api", __name__, url_prefix="/api")
@@ -36,6 +36,12 @@ def _uid():
return u["id"] if u else 0 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): def _arg(name, default=None):
v = request.args.get(name) v = request.args.get(name)
return v if v not in (None, "") else default return v if v not in (None, "") else default
@@ -193,14 +199,26 @@ def api_status():
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'", running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
(uid,)).fetchone()[0] (uid,)).fetchone()[0]
cred = db.secret_state(conn, "cookie", uid) cred = db.secret_state(conn, "cookie", uid)
nxt_bk = backup.next_auto_at(conn) if u["is_admin"] else None
return jsonify({ return jsonify({
"server_time": db.now_str(), "server_time": db.now_str(),
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段 # 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
# 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会 # 决定要不要渲染管理员专属入口(如「日志管理」「备份管理」)。服务端仍会
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。 # 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
"is_admin": bool(u["is_admin"]), "is_admin": bool(u["is_admin"]),
"can_edit_schedule": bool(u["is_admin"]), "can_edit_schedule": bool(u["is_admin"]),
"can_view_logs": 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": { "scheduler": {
"running": sch.running, "running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True, uid), "enabled": db.get_bool(conn, "schedule_enabled", True, uid),
@@ -209,6 +227,13 @@ def api_status():
"catch_up": db.get_bool(conn, "catch_up", True, uid), "catch_up": db.get_bool(conn, "catch_up", True, uid),
"lockfile": os.path.exists(collect.LOCK_PATH), "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, "running_runs": running,
"last_run": dict(last) if last else None, "last_run": dict(last) if last else None,
# 只回「有没有配」与字符数,绝不回凭证内容 # 只回「有没有配」与字符数,绝不回凭证内容
@@ -221,10 +246,40 @@ def api_status():
@bp.post("/collect") @bp.post("/collect")
@login_required @login_required
def api_collect(): 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() u = current_user()
conn = db.get_db()
uid = u["id"]
body = _json_body() 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") frm, to = body.get("from"), body.get("to")
limit = collect.max_range_days(conn)
try: try:
kw = {} kw = {}
if frm: if frm:
@@ -239,7 +294,16 @@ def api_collect():
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59) 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"]: if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
raise BadParam("起始日期不能晚于结束日期") 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: except BadParam as e:
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400 return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
except collect.Busy as e: except collect.Busy as e:
@@ -256,7 +320,7 @@ def api_collect():
"message": str(e)}), code "message": str(e)}), code
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 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}) return jsonify({"ok": True, "result": r})
@@ -307,6 +371,14 @@ def api_maintenance(action):
if action == "vacuum" and not u["is_admin"]: if action == "vacuum" and not u["is_admin"]:
return jsonify({"ok": False, "error": "forbidden", return jsonify({"ok": False, "error": "forbidden",
"message": "数据库整理是整库操作,仅管理员可执行"}), 403 "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: try:
if action == "fill-prompt": if action == "fill-prompt":
try: try:
@@ -336,10 +408,118 @@ def api_maintenance(action):
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404 return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 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}) 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): def _human(n):
for unit in ("B", "KB", "MB", "GB"): for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB": if n < 1024 or unit == "GB":
@@ -363,6 +543,7 @@ def api_settings_get():
s["_globalKeys"] = sorted(config.GLOBAL_KEYS) s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS) s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
s["_canEditGlobal"] = bool(u["is_admin"]) s["_canEditGlobal"] = bool(u["is_admin"])
s["_canManageBackups"] = bool(u["is_admin"])
s["_role"] = "admin" if u["is_admin"] else "user" s["_role"] = "admin" if u["is_admin"] else "user"
return jsonify(s) return jsonify(s)
@@ -397,7 +578,7 @@ def api_settings_post():
db.set_secret(conn, "cookie", "", uid) db.set_secret(conn, "cookie", "", uid)
changed.append(k) changed.append(k)
continue continue
val, err = config.normalize_setting(k, v) val, err = config.normalize_setting(k, v, conn)
if err: if err:
errors.append(err) errors.append(err)
continue continue
@@ -412,7 +593,7 @@ def api_settings_post():
% "、".join(sorted(denied))) % "、".join(sorted(denied)))
if errors: if errors:
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500], 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), return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
"errors": errors, "changed": sorted(changed)}), 400 "errors": errors, "changed": sorted(changed)}), 400
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。 # 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
@@ -425,8 +606,17 @@ def api_settings_post():
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep] if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
for row_uid, skey in stale: for row_uid, skey in stale:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey)) 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"], 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)}) return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
@@ -445,8 +635,16 @@ def api_password():
return jsonify({"ok": False, "message": err}), 400 return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(new), u["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") @bp.post("/profile")
@@ -470,7 +668,7 @@ def api_profile():
if not changed: if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400 return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed), db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
request.remote_addr, u["id"]) _ip(), u["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)}) return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -514,7 +712,7 @@ def api_user_create():
(body.get("display_name") or name).strip()[:64], (body.get("display_name") or name).strip()[:64],
(body.get("email") or "").strip()[:128] or None, adm, db.now_str())) (body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
db.audit(conn, "user_create", me["username"], 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}) return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
@@ -561,6 +759,11 @@ def api_user_update(uid):
return jsonify({"ok": False, return jsonify({"ok": False,
"message": "至少要保留一个启用状态的管理员"}), 400 "message": "至少要保留一个启用状态的管理员"}), 400
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid)) 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 "停用")) changed.append("状态→" + ("启用" if v == "active" else "停用"))
pwd = (body.get("password") or "").strip() pwd = (body.get("password") or "").strip()
if pwd: if pwd:
@@ -569,12 +772,15 @@ def api_user_update(uid):
return jsonify({"ok": False, "message": err}), 400 return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(pwd), uid)) (security.hash_password(pwd), uid))
# 管理员重置口令后,该账号在别处的登录必须立刻失效 ——
# 重置口令的典型场景就是「怀疑账号被盗」。
db.bump_session_ver(conn, uid)
changed.append("密码") changed.append("密码")
if not changed: if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400 return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "user_update", me["username"], db.audit(conn, "user_update", me["username"],
"修改用户 %s:%s" % (row["username"], "、".join(changed)), "修改用户 %s:%s" % (row["username"], "、".join(changed)),
request.remote_addr, me["id"]) _ip(), me["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)}) return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -605,7 +811,7 @@ def api_user_delete(uid):
conn.execute("DELETE FROM users WHERE id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,))
db.audit(conn, "user_delete", me["username"], db.audit(conn, "user_delete", me["username"],
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"), "删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
request.remote_addr, me["id"]) _ip(), me["id"])
return jsonify({"ok": True, "message": "已删除 " + row["username"]}) return jsonify({"ok": True, "message": "已删除 " + row["username"]})
+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 %}
+3
查看文件
@@ -32,6 +32,9 @@
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见; {# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #} 服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
{% if cur.is_admin %} {% 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> <a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% endif %} {% endif %}
{% if cur.is_admin %} {% if cur.is_admin %}
+24 -2
查看文件
@@ -10,6 +10,8 @@
</div> </div>
<div class="actions"> <div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a> <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>
</div> </div>
@@ -57,12 +59,32 @@
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label> <label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" 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> <button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。 <p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
修改成功后当前会话仍有效,不必重新登录。</p> 且不能是常见弱口令。
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
「怀疑被盗所以改密码」才能真正生效。</p>
</form> </form>
</section> </section>
</div> </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"> <section class="card">
<div class="cardhead"> <div class="cardhead">
<h2>我的采集凭证</h2> <h2>我的采集凭证</h2>
+11 -3
查看文件
@@ -31,7 +31,10 @@
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false"> <input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label> </label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。 <p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p> 保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
把「每日调度时刻上限」调大。</p>
<label class="row"><span>启动补跑</span> <label class="row"><span>启动补跑</span>
<select name="catch_up"> <select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option> <option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
@@ -75,11 +78,16 @@
<hr class="sect-divider"> <hr class="sect-divider">
<h3>历史回填</h3> <h3>历史回填</h3>
<form id="formBackfill"> <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> <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> <button class="btn" type="submit">按区间补采</button>
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。 <p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p> <br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
这三条是为了避免把云端接口与本站线程池打满。
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
</form> </form>
</section> </section>
</div> </div>
+213 -6
查看文件
@@ -28,13 +28,16 @@
""" """
import csv import csv
import io import io
import json
import os import os
import sqlite3 import sqlite3
import tempfile
import zipfile
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template, from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
request, send_from_directory, session, url_for) 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, from ..security import (admin_required, current_user, is_admin, login_required,
safe_next) safe_next)
@@ -42,7 +45,13 @@ bp = Blueprint("views", __name__)
def _ip(): 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(): def _uid():
@@ -104,10 +113,15 @@ def login():
username = (request.form.get("username") or "").strip() username = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or "" pwd = request.form.get("password") or ""
# 先记一次「尝试」(含成功)。只按失败计数会被「慢慢撞」绕过:
# 攻击者只要把失败次数控制在阈值以下就能无限试。
security.note_try(ip)
left = security.auth_locked(ip, username) left = security.auth_locked(ip, username)
if left: if left:
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip) reason = security.auth_block_reason(ip, username)
flash("登录失败次数过多,请 %d 秒后再试" % left, "error") security.audit_login_fail(conn, username,
"已限速(%s),剩余 %d 秒" % (reason, left), ip)
flash(security.auth_block_message(reason, left), "error")
return render_template("login.html", return render_template("login.html",
**_login_ctx(conn, next_url=nxt, username=username, **_login_ctx(conn, next_url=nxt, username=username,
need_captcha=True)), 429 need_captcha=True)), 429
@@ -127,7 +141,9 @@ def login():
if user is None: if user is None:
n = security.note_auth_fail(ip, username) n = security.note_auth_fail(ip, username)
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip) 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 为空, # 必须把 next 显式回填:失败后 request.args 为空,
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。 # 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
return render_template("login.html", return render_template("login.html",
@@ -176,9 +192,11 @@ def register():
ctx = _register_ctx(username=username, display_name=display, email=email, ctx = _register_ctx(username=username, display_name=display, email=email,
need_captcha=True) need_captcha=True)
security.note_try(ip)
left = security.auth_locked(ip, username) left = security.auth_locked(ip, username)
if left: 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 return render_template("register.html", **ctx), 429
# 注册一律要验证码:这是唯一能让陌生人写库的入口 # 注册一律要验证码:这是唯一能让陌生人写库的入口
@@ -313,9 +331,14 @@ def tasks():
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall() " LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
s = db.get_settings(conn, uid=uid) s = db.get_settings(conn, uid=uid)
pages = max(1, (total + size - 1) // size) pages = max(1, (total + size - 1) // size)
# 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位,
# 而不是让用户填完了再吃一个 400/409/429。
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid), return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
s_times=s.get("schedule_times") or "", s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12", 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(), can_edit=is_admin(),
page=page, pages=pages, total=total, page=page, pages=pages, total=total,
page_window=_page_window(page, pages), page_window=_page_window(page, pages),
@@ -572,6 +595,190 @@ def records_export():
return resp 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") @bp.get("/index.html")
def legacy_index(): def legacy_index():