From f36149efc3dbeadcc9e5938b7d79f597cffb5990 Mon Sep 17 00:00:00 2001 From: wangchuanli Date: Fri, 18 Sep 2026 11:13:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AE=89=E5=85=A8):=20=E5=AF=B9=E5=A4=96?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E9=9D=A2=E5=8A=A0=E5=9B=BA=20+=20=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=8E=BB=20AI=20=E5=8C=96=EF=BC=88v1.5.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 界面(去 AI 味): - 大屏页清除 114 处生成器残留属性 data-page-node-id - 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、 去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点 - 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号 - 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令) 安全与隐私(按「将会被公网访问」收口): - 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal) - 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验 - 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异 - /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle) - 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限 - 表名与库文件路径只对管理员下发;大屏页所有数据插值转义 - --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器 文档: - DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行 - SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项 - .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库) 版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4) 验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题 --- .gitignore | 6 + Dockerfile | 2 +- README.md | 5 +- SECURITY.md | 50 +- docs/CHANGELOG.md | 110 ++++ docs/DEPLOYMENT.md | 116 +++- docs/USER-GUIDE.md | 6 +- manage.py | 23 +- tools/check_docs.py | 4 + tools/smoke.py | 118 +++- workbuddy_portal/__init__.py | 19 +- workbuddy_portal/backup.py | 3 + workbuddy_portal/collect.py | 15 +- workbuddy_portal/config.py | 49 ++ workbuddy_portal/query.py | 31 +- workbuddy_portal/security.py | 96 ++- workbuddy_portal/web/api.py | 35 +- workbuddy_portal/web/static/css/app.css | 233 ++++--- .../web/static/dashboard/index.html | 574 +++++++++--------- workbuddy_portal/web/templates/backups.html | 33 +- workbuddy_portal/web/templates/base.html | 3 +- workbuddy_portal/web/templates/config.html | 42 +- workbuddy_portal/web/templates/login.html | 1 - workbuddy_portal/web/templates/overview.html | 7 +- workbuddy_portal/web/templates/profile.html | 20 +- workbuddy_portal/web/templates/records.html | 4 +- workbuddy_portal/web/templates/tasks.html | 26 +- workbuddy_portal/web/templates/users.html | 11 +- workbuddy_portal/web/views.py | 21 +- 29 files changed, 1105 insertions(+), 558 deletions(-) diff --git a/.gitignore b/.gitignore index 443bca7..472b73c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,12 @@ venv/ .DS_Store Thumbs.db +# ---- AI 助手的工作记忆 ---- +# .codebuddy/ 里是会话记忆与技能缓存,属于「开发过程产物」而不是项目资产, +# 与代码版本无关。对外开源后也不该混进仓库(README 里不需要它, +# 而 tools/check_docs.py 会把其中的 .md 当成项目文档一起扫)。 +.codebuddy/ + # ---- 部署本地配置(含明文密码,只提交 .env.example)---- .env .env.local diff --git a/Dockerfile b/Dockerfile index dfcfc26..b60c100 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime LABEL org.opencontainers.image.title="WorkBuddy Portal" \ org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \ - org.opencontainers.image.version="1.4.0" \ + org.opencontainers.image.version="1.5.0" \ org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal" ENV PYTHONUNBUFFERED=1 \ diff --git a/README.md b/README.md index c42aa06..2911651 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ | 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 | | 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 | | 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** | -| 版本 | v1.4.0 | +| 版本 | v1.5.0 | | **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) | **目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) · @@ -140,7 +140,8 @@ docker compose up -d > v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密); > v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级); > v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`), -> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。 +> **无数据搬运**;v1.4.0 → v1.5.0 **没有库结构变更**(纯界面与安全加固)。 +> 四次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。 ### 第一次使用必做 diff --git a/SECURITY.md b/SECURITY.md index e70439a..3f0b50b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,15 +3,34 @@ ## 支持范围 本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、 -资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。 +资源上限、采集跨度硬顶都补上了),**1.5.0 又按同一前提收口了一轮信息泄漏、注入与读接口滥用**。 +即便如此,仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。 | 版本 | 是否接受安全修复 | |---|---| -| `1.4.x`(当前) | ✅ | +| `1.5.x`(当前) | ✅ | +| `1.4.x` | ⚠️ 可用,但建议升级(1.5.0 收口了内部异常/表名外泄、响应头注入、导出路径穿越、读接口无刹车) | | `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) | | `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) | | `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) | +> **1.5.0 收口了什么**(都不是「可直接利用的高危漏洞」,而是**对外时迟早出事**的那一类): +> +> 1. **信息泄漏**:接口的兜底异常原本把 `str(e)` 原样回给客户端(含绝对路径与 SQL 片段), +> 现在只回一个 8 位事件号,细节进服务端日志;`/api/manifest` / `/api/bundle` 里的 +> **表名与库文件路径**也只对管理员下发。另外补掉了登录接口的**用户名枚举时序侧信道** +> (账号不存在时也走一次同代价的哑哈希)。 +> 2. **注入**:导出文件名含用户名,而用户名并不总是注册正则的产物 +> (`manage.py passwd` 建号、老库升级上来的名字都可能带引号或 CR/LF)—— +> 直接拼 `Content-Disposition` 就是**响应头注入**;`export_csv` 的账号名直拼路径则是 +> **路径穿越**。两者都改为先收敛字符集,建号也补上了用户名校验。 +> 3. **资源滥用**:读接口原本**没有任何刹车**,一个注册账号循环调 `/api/bundle` +> 就能持续吃满 CPU 与出口带宽。现在 `/api/*` 按账号(未登录按 IP)限速 240 次 / 60 秒。 +> 4. **隐私落盘**:进程 `umask` 收到 `0077` —— 数据目录里是**对话正文**、凭证密文、主密钥 +> 与它们的明文导出副本,默认 `0644` 等于同机任何用户可读。 +> 5. **调试器**:`--debug` 不再允许绑定对外地址(Werkzeug 调试器 = 任意代码执行, +> 而 `--host` 的默认值恰好是 `0.0.0.0`)。 + > **1.4.0 修掉了什么**(三条 P0): > 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。 > 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF, @@ -76,7 +95,8 @@ | 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` | | 失败限速(双维度、**强度刻意不同**) | **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` | +| **用户名枚举的时序侧信道** | 口令校验对**不存在的账号**也走一次同代价的哑哈希(`_DUMMY_HASH`)。否则「账号不存在」比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在 | `security.login_ok` / `_dummy_verify` | +| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP、**`Permissions-Policy`(显式关掉地理位置/麦克风/摄像头/支付/USB)**;`/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` | @@ -119,6 +139,19 @@ | 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` | | 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` | +### 信息泄漏收敛(v1.5.0) + +不致命,但都是**踩点阶段最好用**的材料,所以统一收掉。 + +| 项 | 做法 | 位置 | +|---|---|---| +| **内部异常不外泄** | 接口的兜底 `except Exception` 不再回 `str(e)`(那会带绝对路径、SQL、`sqlite3` 报错),改为**完整堆栈进服务端日志 + 一个 8 位事件号**。面向用户写的业务异常(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError`)不受影响,它们本来就是给用户看的 | `web/api.py::_internal` | +| 内部实现清单只给管理员 | `/api/manifest`、`/api/bundle` 的 `sources`(表名)与 `archive`(库文件路径)对普通账号为空;大屏页「数据源」卡片随之收起 | `query.manifest(sources=…)` | +| **响应头注入收口** | 导出文件名一律先收敛成 ASCII 安全名(只留 `[A-Za-z0-9._-]`、打平 `..`),再按 RFC 5987 附上原名。含引号 / CR / LF 的用户名不可能再拼出畸形响应头 | `security.safe_filename` / `content_disposition` | +| **导出路径穿越收口** | `export_csv` 的账号名 tag 同样收敛,且结果固定落在 `EXPORT_DIR` 之下。根因也堵了:`manage.py passwd` **建号时补上用户名校验**(与注册页同一套) | `collect.export_csv`、`manage.py` | +| 上传体量反馈一致 | `413` 返回 JSON(与其它接口错误同形状)。不加这一层时 Flask 吐 HTML 页,前端 `r.json()` 会炸成「Unexpected token <」 | `workbuddy_portal/__init__.py` | +| **访问日志不带查询串** | `_access_log` 记的是 `request.path` —— 若记 `?q=<搜索词>`,prompt 片段会顺着搜索词进日志文件 | `security._access_log` | + ### 资源与频率限制(对外提供服务时) 单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。 @@ -129,6 +162,7 @@ | **采集跨度硬顶** | `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` | +| **读接口限速** | `/api/*` 按「账号」计数(未登录退化成来源 IP,**不做 DB 回查**)限速 **240 次 / 60 秒**,超限 `429`。重点挡的是 `/api/bundle` —— 它要算全量逐日聚合、还会下发最多 2 万条明细,把它放进 `for` 循环就能持续吃掉 CPU 与出口带宽(这不需要任何漏洞)。阈值刻意宽松:大屏切一次筛选只发 1~2 个请求 | `security.api_rate_ok` | | 容器资源上限 | `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` | @@ -152,7 +186,9 @@ |---|---|---| | 容器权限 | 运行层非 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-` | `config._instance_init`、`backup._restore_instance` | -| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` | +| **进程级落盘权限(v1.5.0)** | 启动时把 `umask` 收到 `0077`,此后**本进程新建的一切文件**天然是 owner-only:SQLite 的 `-wal` / `-shm`、导出 CSV、备份 zip、日志一并覆盖。刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,而漏掉的那个文件里往往正好是最新的**对话正文**。配套 `harden_dir()` / `harden_file()` 收紧遗留的 0755 目录与已存在的文件 | `config.harden_process` / `harden_dir` / `harden_file` | +| **调试器不绑对外地址** | `manage.py serve --debug` 在 `--host` 非回环时**直接拒绝启动**(退出码 2)。Werkzeug 的交互式调试器等于任意代码执行,而 `--host` 默认就是 `0.0.0.0` —— 这条把「本机调试习惯」和「公网部署」之间的那个坑堵死 | `manage.py cmd_serve` | +| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB`(超限由 `413` 处理器转成 JSON) | `workbuddy_portal/__init__.py` | | 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` | **绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、 @@ -222,3 +258,9 @@ 「已保存但无法解密」 - [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**, 且访问 `/backups` 返回 403(用普通账号各试一次) +- [ ] **升级到 1.5.0 后跑一次 `python tools/smoke.py`**:第 9 节专门验「对外暴露面」 + (响应头收敛 / 异常不外泄 / 读接口限速 / 安全响应头 / 普通账号拿不到数据源清单) +- [ ] 确认容器里进程的落盘权限已收紧:`docker compose exec portal sh -c 'umask'` 应为 `0077`, + 且 `ls -l /app/data` 的目录权限是 `drwx------` +- [ ] 确认从外部看到的响应头里有 `Permissions-Policy`(1.5.0 新增) +- [ ] 确认 `--debug` 没有被用在对外地址上:`python manage.py serve --debug` 应当**直接报错退出** diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8ef89a9..8668a57 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,116 @@ --- +## [1.5.0] — 2026-09-18 + +**主题:按「将会被公网访问」重新审一遍界面与暴露面** + +两件事:① 清掉界面里的 AI 生成残留与「AI 味」,把视觉从「霓虹渐变」改回工程控制台; +② 按「能被公网访问」的前提逐条收口信息泄漏、注入与资源滥用。 +**没有库结构变更**(`user_version` 仍是 4),升级不需要手工介入。 + +### 安全与隐私加固(对外提供服务) + +**主题:把「能被公网访问」当成前提,逐条收口信息泄漏与资源滥用** + +这一轮没有新增功能,全部是收口。原则:**能把信息少给一点就少给一点, +能把权限多收一层就多收一层**。 + +**信息泄漏(这类问题的共同点是:平时看不出问题,踩点阶段最好用)** + +- **内部异常不再直出**。`/api/collect`、`/api/maintenance/*`、`/api/backups*` 的兜底 + `except Exception` 原本把 `str(e)` 原样回给客户端 —— 那里面会带绝对路径 + (`/app/workbuddy_portal/...`)、SQL 语句、`sqlite3` 的报错。现在统一走 + `web/api.py::_internal`:完整堆栈写进服务端日志并打一个 **8 位事件号**, + 客户端只拿到事件号。既不影响「出事了去查日志」,也不送材料。 + (`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError` 这些**面向用户写的** + 业务异常不受影响,它们的文案本来就是给用户看的。) +- **普通账号拿不到内部实现清单**。`/api/manifest`、`/api/bundle` 里的 `sources` + (表名 `usage_records`、`daily 聚合视图` …)与 `archive`(`data/usage.sqlite`) + 只对管理员下发;非管理员的大屏页「数据源」卡片自动收起,避免留一个只有表头的空壳。 +- **响应头注入**。导出文件名里含用户名,而用户名**并不总是**注册正则的产物 + (`manage.py passwd` 建号时不校验、老库升级上来的名字也可能带引号或 CR/LF)。 + 一旦名字里有引号或换行,直接拼 `Content-Disposition` 就是响应拆分。 + 新增 `security.safe_filename()` 与 `security.content_disposition()`: + 收敛成 ASCII 安全名 + 按 RFC 5987 附上原名,`/records/export` 与 + `/profile/export` 都改走它。 +- **路径穿越**。`collect.export_csv()` 的账号名 tag 直接拼进文件路径 —— + 一个叫 `..\..\x` 的账号能把 CSV 写到数据目录之外。现在只保留 `[A-Za-z0-9._-]`, + 且结果固定落在 `EXPORT_DIR` 之下。同时 `manage.py passwd` **建号时补上用户名校验** + (与注册页同一套),从根上不再产生这类名字。 +- **用户名枚举的时序侧信道**。`login_ok` 在账号不存在时不执行任何哈希计算, + 比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在。 + 现在对不存在的账号也走一次**同代价的哑哈希**,两条路径耗时对齐。 + +**隐私:落盘权限(这条是对外部署最容易被忽略的)** + +- 数据目录里躺着的是:`usage_records.prompt`(**用户与 AI 的完整对话正文**)、 + 凭证密文、`instance.json`(主密钥)、`exports/*.csv`(对话正文的**明文**副本)、 + `backups/*.zip`(全库 + 主密钥)。默认 `umask 022` 会把它们留成 `0644 / 0755`, + 也就是**同机任何用户都能读**。 +- 新增 `config.harden_process()`:在进程启动时把 `umask` 收到 `0077`, + 此后新建的一切文件(含 SQLite 的 `-wal` / `-shm`)天然是 owner-only。 + 刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏, + 而漏掉的那个文件里往往正好是最新的对话正文。 +- 配套 `harden_dir()` / `harden_file()` 收紧**已经存在**的目录(老版本留下的 0755) + 以及导出 CSV、备份 zip 落盘后的权限。Windows 无此模型,函数直接跳过。 + +**资源滥用** + +- **读接口限速**:登录有 IP 锁定、注册有配额、采集有最小间隔,但读接口原本**没有任何刹车** —— + 一个注册账号把 F12 里的 `/api/bundle` 请求放进 `for` 循环就能持续吃 CPU 与出口带宽 + (那个接口要算全量逐日聚合,还会下发最多 2 万条明细)。现在 `/api/*` 按 + 「账号(未登录时按来源 IP)」限速 240 次 / 60 秒,超出返回 `429`。 + 阈值给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到。 + +**其它** + +- **`--debug` 不再能绑到对外地址**。Werkzeug 的交互式调试器等于任意代码执行, + 而 `--host` 默认就是 `0.0.0.0`;`python manage.py serve --debug` 会直接把 RCE 挂上公网。 + 现在绑非回环地址直接拒绝启动,并提示本机调试的正确写法。 +- 新增 `Permissions-Policy` 响应头(显式关掉地理位置 / 麦克风 / 摄像头 / 支付 / USB)。 +- 新增 `413` 处理器:请求体超限时接口返回 JSON 而不是 Flask 默认 HTML 页 + (前端 `r.json()` 在 HTML 上会炸,表现成「Unexpected token <」)。 +- 文档:新增 [部署指南第十三节「安全与隐私基线」](DEPLOYMENT.md#十三安全与隐私基线) —— + 代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。 + +验证:`python tools/smoke.py` **ok=264 / fail=0**(第 9 节专门覆盖上述各项: +`safe_filename` 边界、真实导出响应头不含引号/换行、每个 `except Exception` 都走 `_internal`、 +读接口限速、安全响应头、大屏页已转义、普通账号拿不到数据源清单)。 + +### 界面去 AI 化 + +**主题:清掉界面里的 AI 生成残留与「AI 味」** + +- **大屏页清理生成器残留**:`static/dashboard/index.html` 里 **114 处** + `data-page-node-id="…"`(页面生成工具给每个节点打的标记)全部删除, + 文件从 64 KB 降到 58 KB。这类属性没有任何运行时作用,只是生成痕迹。 +- **视觉系统重做**(`static/css/app.css` + 大屏内联样式):由「深色底 + 霓虹渐变 + 辉光」 + 改为中性的工程控制台风格 —— 灰阶打底、单一蓝色强调色。具体删掉了: + `radial-gradient` 页面背景、按钮/激活态/分页/徽标的 `linear-gradient`、 + `box-shadow` 发光(品牌点、主按钮、日历格)、渐变文字(错误页大号状态码)、 + 以及标题前的彩色装饰条(`.pagehead h1::before`、`.card h2::before`、`h1::before`)。 +- **KPI 改用灰度分层**:原来每张卡一条彩虹色左色条,现在 `--c` 只渲染成一个 8px + 状态点(`采集健康`/`自动备份` 这类状态卡仍能一眼区分),其余靠灰度与字重。 +- **图表配色同步降饱和**:ECharts `PALETTE`、日历 5 级色阶、热力图 `visualMap` + 与后台共用同一套语义色;数据系列改用蓝色阶 + 1 个琥珀色对比项。 +- **文案精简**:删掉说教式长段落(`配置管理` 页整张「为什么采集参数是只读的」说明卡)、 + 把只给结论即可的提示压成一句话;`任务管理`/`配置管理`/`备份管理`/`用户管理` + 的长提示同步收敛。 +- **修掉两个真实缺陷**(都在这次清理中暴露): + - `templates/profile.html` 的 Markdown 星号漏进 HTML —— `**任何时候都不回传明文**` + 会在页面上原样渲染成星号; + - 登录页写着「首次部署默认账号 `admin` / `admin123`」,而 1.4.0 起已改用随机口令, + 该提示会把人引向一个永远登不上的口令,已删除。 +- **文档同步**:`docs/USER-GUIDE.md` 中引用上述被删文案的两处说明一并更新。 + 界面截图 `docs/images/*.png` 仍是旧观感,需要用 `python tools/shots.py` 重出。 + +验证:`python tools/smoke.py` 全绿(ok=233 / fail=0),其中 +「页面 class 与 app.css 选择器对账」确认新版样式表没有漏掉任何模板在用的类名; +`python tools/check_docs.py` 0 处问题。 + +--- + ## [1.4.0] — 2026-09-16 **主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制** diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7ec3f2b..c412368 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -18,6 +18,7 @@ - [十、日常巡检](#十日常巡检) - [十一、排错](#十一排错) - [十二、配置项速查](#十二配置项速查) +- [十三、安全与隐私基线](#十三安全与隐私基线) --- @@ -437,7 +438,7 @@ name: workbuddy-portal services: portal: - image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 + image: git.iwali.top/wangchuanli/workbuddy-portal:1.5.0 container_name: workbuddy-portal restart: unless-stopped init: true # tini 接管 PID 1,docker stop 能干净传到 python @@ -675,7 +676,7 @@ WB_TRUST_PROXY=1 ```bash export GITEA_TOKEN=<你的令牌> tools/push-all.sh # 推 main 分支 + 镜像 latest -tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送 +tools/push-all.sh 1.5.0 # 同时打一个版本 tag 并推送 ``` 脚本做的事: @@ -713,15 +714,15 @@ docker login git.iwali.top -u wangchuanli docker compose build docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \ - git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 + git.iwali.top/wangchuanli/workbuddy-portal:1.5.0 docker push git.iwali.top/wangchuanli/workbuddy-portal:latest -docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 +docker push git.iwali.top/wangchuanli/workbuddy-portal:1.5.0 ``` ### 7.4 验证远端 ```bash -docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 +docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.5.0 # 或 curl -s -u wangchuanli:TOKEN \ https://git.iwali.top/api/v1/packages/wangchuanli?type=container @@ -944,8 +945,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.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` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 | +| **1.4.0 → 1.5.0**(界面改版 + 公网加固) | 4 → 4 | **没有任何库结构变更**,`init_db()` 只做幂等校验。改动都在代码与界面上 | 不需要 | -三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: +四次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: ```bash docker compose logs portal | grep -iE "迁移|migrat|提升|promote" @@ -1285,7 +1287,7 @@ docker compose exec portal python manage.py users | 命令 | 作用 | |---|---| | `init [--user U] [--password P]` | 初始化 / 迁移数据库,创建首个管理员(幂等) | -| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度) | +| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度)。`--debug` **只允许绑回环地址**(Werkzeug 调试器可执行任意代码) | | `collect [-u U]` | 执行一次增量采集(不传 `-u` 则逐个启用账号) | | `migrate-csv [PATH] [-u U]` | 从 v1.0 的 CSV 导入(只读,可重复) | | `import-xlsx PATH [-u U]` | 从官网导出的 xlsx 导入 | @@ -1302,3 +1304,103 @@ docker compose exec portal python manage.py users | `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` | Docker 部署下前面加 `docker compose exec portal`。 + +--- + +## 十三、安全与隐私基线 + +> 面向**要把这个实例挂到公网 / 开放注册**的部署者。 +> 代码层已经替你关掉的那部分不需要配置;需要**你决定**的部分在 13.2 与 13.4 节。 + +### 13.1 代码层已经做到的(不用配置,改坏了反而有风险) + +| 威胁 | 处理方式 | 入口 | +|---|---|---| +| 撞库 / 爆破 | 图形验证码 + **按来源 IP 硬锁** + 用户名软退避 + 单 IP 尝试总量(40 次 / 5 分钟) | `security.py` | +| 用户名枚举 | 口令校验对不存在的账号也走一次**哑哈希**,两条路径耗时对齐;登录失败文案统一 | `security.login_ok` | +| 会话固定 / 劫持 | 登录时重建会话(顺带换掉 CSRF 与验证码 id);`HttpOnly` + `SameSite=Lax`;改密 / 重置 / 停用后 `session_ver` 立即作废旧会话 | `security.login_session` | +| CSRF | 所有非 GET 请求在 `before_request` 里统一校验;退出登录也改成 POST | `security.check_csrf` | +| XSS | Jinja 自动转义 + 前端手动 `esc()`(含 ECharts 的 HTML tooltip)+ CSP + `nosniff` | `security.CSP`、`dashboard/index.html` | +| 点击劫持 | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `apply_security_headers` | +| 开放重定向 | `next=` 只允许站内相对路径 | `security.safe_next` | +| **响应头注入** | 导出文件名一律先收敛成 ASCII 安全名,再按 RFC 5987 附上原名 | `security.content_disposition` | +| **路径穿越** | 备份文件名走 `safe_name()`;导出 CSV 的账号名同样收敛(`manage.py passwd` 建号也补了用户名校验) | `backup.safe_name`、`collect.export_csv` | +| **内部信息泄漏** | 兜底异常只回一个事件号(完整堆栈进服务端日志);普通账号拿不到表名与库文件路径 | `web/api.py::_internal`、`query.manifest` | +| 资源耗尽 | 采集三道闸门(互斥 / 最小间隔 / 跨度上限)+ 重操作最小间隔 + **读接口限速(240 次 / 分钟 / 账号)** + 容器 `cpus`/`mem_limit`/`pids_limit` | `web/api.py`、`security.api_rate_ok`、`docker-compose.yml` | +| 凭证泄漏 | Cookie 静态加密入库;页面与接口只回「长度 + 尾 4 位」;导出与备份都不含明文 | `crypto.py`、`db.get_secret` | +| **落盘权限** | 进程 `umask 0077` + 目录 0700 / 文件 0600(SQLite 的 `-wal`/`-shm`、导出 CSV、备份 zip 一并覆盖) | `config.harden_process` | +| 调试器 RCE | `--debug` 只允许绑定回环地址,绑对外地址直接拒绝启动 | `manage.py cmd_serve` | + +### 13.2 公网暴露前你必须自己做的 + +1. **上 HTTPS,并把开关一起打开**(缺一个就是半截配置): + + ``` + WB_FORCE_HTTPS=1 + WB_COOKIE_SECURE=1 + # 反向代理终止 TLS 时还要:WB_TRUST_PROXY=1(且代理必须重写 XFF) + ``` + + 见 [第六节](#六反向代理与-https)。 + +2. **直连公网时 `WB_TRUST_PROXY` 必须留 0**。置 1 的前提是「**你自己的**反代会重写 + `X-Forwarded-For`」;否则攻击者每换一个伪造的 XFF,验证码限速、注册配额、 + 登录锁定三道 IP 防线会同时失效。 +3. **显式指定管理员口令**:`WB_ADMIN_PASSWORD=<强口令>`。留空时程序会生成随机口令并 + 只在启动日志里打印一次,忘了抄就只能 `manage.py passwd` 重置。 +4. **决定要不要开放注册**:`allow_register`(默认 `1`)。开放就确认 + `captcha_policy=always` 与 `register_max_per_ip` 的取值。 +5. **别把备份卷和 `data/` 卷暴露出去**:备份 zip 里含**主密钥**(`instance.json`), + 拿到它等于拿到全库凭证的明文。备份目录默认不在 `data/` 内,容器里挂的是独立卷。 +6. **在应用前面再加一层限制**(可选但强烈建议):反代 / 云安全组做 IP 白名单或接 WAF。 + 应用层的限速是最后一道,不该是唯一一道。 + +### 13.3 隐私:本系统存了哪些个人数据 + +| 数据 | 存在哪 | 谁能看到 | 备注 | +|---|---|---|---| +| **对话正文**(`User Prompt`) | `usage_records.prompt`;管理员导出时落到 `data/exports/*.csv` | 仅本人(页面 / 导出);管理员在**备份归档**里也能拿到 | 本系统里最敏感的一类数据。入库按 `max_prompt` 截断(默认 2048 字符) | +| 云端凭证(Cookie / UA) | `settings.value`,**密文** | 仅本人,且只能看到「长度 + 尾 4 位」 | 主密钥在 `data/instance.json`(0600) | +| 账号资料 | `users`(用户名 / 显示名 / 邮箱) | 本人 + 管理员 | 邮箱选填 | +| 来源 IP | `users.register_ip`、`users.last_login_ip`、`audit_log.ip` | 仅管理员 | 用于每日注册配额与事后追责 | +| 采集运行日志 | `collect_runs.detail`、`logs/app.log` | 本人(自己的);`/logs` 整页仅管理员 | 访问日志只记路径,**不记 query string** —— 否则 `?q=<搜索词>` 会把 prompt 片段带进日志文件 | + +三条数据边界由**服务端**强制,不靠界面隐藏: + +- 普通账号的一切读写都带 `user_id = 当前账号`;越权写入直接 `400`,并记一条 + `settings_rejected` 审计; +- 管理员在「用户管理」看得到账号列表与登录 IP,但**看不到任何人的用量与凭证**; +- 凭证明文只在「真正要拿它对外发请求」的那一刻解出来,不进日志、不进响应体。 + +### 13.4 保留期与删除:需要你自己定的部分 + +**代码刻意不做自动清理** —— 自动删数据比留数据危险得多。所以下面这些是部署者的事: + +- `prompt` 与 `audit_log` 会一直留着。需要按时间裁剪时(**先 `manage.py backup`**): + + ```sql + -- 例:清掉 180 天前的对话正文(保留行与积分,只去正文) + UPDATE usage_records SET prompt='' WHERE day < date('now','-180 day'); + -- 例:清掉 365 天前的审计 + DELETE FROM audit_log WHERE at < date('now','-365 day'); + ``` + + 改完用 `manage.py vacuum` 回收空间。 +- **删除账号已经是「被遗忘权」的落地方式**:「用户管理 → 删除」默认**连同该账号的用量 + 数据与 Cookie 一起删除**;用户自己可以走「个人中心 → 导出我的全部数据」把数据带走。 +- **导出文件的留存**:`data/exports/*.csv` 是对话正文的明文副本,没有谁会自动清。 + 容器里它在 `wb_data` 卷内 —— 保留多久由你决定,别让它一直躺在磁盘上。 + +### 13.5 上线前的自检 + +```bash +# 1) 应用层回归(第 9 节专门验「对外暴露面」:响应头收敛 / 异常不外泄 / 读接口限速) +python tools/smoke.py + +# 2) 从外部看到的响应头 +# 期望看到 CSP / Permissions-Policy / X-Frame-Options / nosniff / Referrer-Policy +curl -sI https://你的域名/login | grep -iE 'content-security|permissions-policy|x-frame|nosniff|referrer' + +# 3) 确认没有把调试器挂出去(这条应当直接报错退出,而不是启动成功) +python manage.py serve --debug +``` diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f4c1c5c..eddb37a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -413,13 +413,11 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过, 重新粘贴一次即可。 -> 凭证卡片上会有一行提示写着「**这一块是你唯一可以修改的配置**」—— -> 看到它就找对地方了。 +> 凭证卡片上会有一行提示写着「普通账号只有这一块可改」——看到它就找对地方了。 ### 9.2 采集参数(只读) -普通账号在这一块看到的是**只读表格**,输入框不可编辑,页面上还有一张 -「**为什么采集参数是只读的**」说明卡: +普通账号在这一块看到的是**只读表格**,输入框不可编辑: | 参数 | 默认 | 说明 | |---|---|---| diff --git a/manage.py b/manage.py index c37414c..ce00579 100644 --- a/manage.py +++ b/manage.py @@ -15,7 +15,7 @@ 常用: python manage.py init 初始化数据库(建表 + 默认配置 + 管理员) python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程) - python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份) + python manage.py serve --debug --host 127.0.0.1 开发模式(--debug 只允许绑回环地址) python manage.py collect 为**所有已启用账号**各跑一次增量采集 python manage.py collect -u alice 只为 alice 采集 python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径) @@ -127,9 +127,18 @@ def cmd_init(args): def cmd_serve(args): from workbuddy_portal import create_app - app = create_app(start_scheduler=not args.no_scheduler) host = args.host or config.DEFAULT_HOST port = args.port or config.DEFAULT_PORT + # --debug 会打开 Werkzeug 的交互式调试器:**任意 Python 代码执行**。 + # 它虽然有 PIN,但 PIN 会打印在控制台、且在某些部署下可由机器信息推导出来, + # 把它绑到 0.0.0.0(默认值就是这个)等于把 RCE 直接挂上公网。 + # 这里直接拒绝:调试要么在本机回环地址上做,要么就别开调试器。 + if args.debug and host not in ("127.0.0.1", "localhost", "::1"): + _p("[error] --debug 只能绑定回环地址(当前 --host %s)。" % host) + _p(" Werkzeug 调试器可执行任意 Python 代码,绑到对外地址等于开放 RCE。") + _p(" 本机调试请用:python manage.py serve --debug --host 127.0.0.1") + return 2 + app = create_app(start_scheduler=not args.no_scheduler) if args.debug: app.run(host=host, port=port, debug=True) return @@ -420,6 +429,13 @@ def cmd_passwd(args): if not pwd: _p("[error] 新账号必须给出密码") return 2 + # 建号必须走与注册页同一套用户名校验。绕开它的后果不是「名字不好看」: + # 导出文件名与下载响应头都直接拼账号名,一个带 `/`、`..`、引号或 CR/LF + # 的名字会变成路径穿越 / 响应头注入。这里从根上堵掉。 + problem = security.username_problem(args.user) + if problem: + _p("[error] 用户名不合规:%s" % problem) + return 2 conn.execute( "INSERT INTO users(username,password_hash,display_name,is_admin,status," " created_at) VALUES(?,?,?,?,'active',?)", @@ -705,6 +721,9 @@ def main(): if not getattr(args, "func", None): ap.print_help() return 0 + # 先收紧 umask 再建目录:这样 SQLite 库、-wal/-shm、导出 CSV、备份 zip + # 从「被创建的那一刻」就是 owner-only,不必逐个事后 chmod。 + config.harden_process() config.ensure_dirs() return args.func(args) or 0 diff --git a/tools/check_docs.py b/tools/check_docs.py index 28d7236..4b01659 100644 --- a/tools/check_docs.py +++ b/tools/check_docs.py @@ -27,6 +27,10 @@ import sys SKIP_DIRS = { ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode", + # AI 助手的工作记忆与技能缓存:是开发过程产物,不是项目文档。 + # 它已在 .gitignore 里,这里一并跳过,免得自检报告被一堆无关 .md 淹没 + # (而且那些笔记里的链接是给助手看的,不该按项目文档的规则去校验)。 + ".codebuddy", } # markdown 链接:[文本](目标) diff --git a/tools/smoke.py b/tools/smoke.py index d781edb..be62722 100644 --- a/tools/smoke.py +++ b/tools/smoke.py @@ -17,6 +17,7 @@ 4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.) 5. CSV 导出可被标准 csv 解析、列数一致 6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误) + 9. 对外暴露面:下载响应头收敛 / 内部异常不外泄 / 读接口限速 / 安全响应头 权限模型(改断言前先读这一行): 普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent); @@ -487,7 +488,7 @@ def run() -> None: chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf) chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf) chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf) - chk("⑨ 配置页说明范围", "唯一可以修改" in cf) + chk("⑨ 配置页说明范围", "只有这一块可改" in cf) # ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口) st, sj = page(cli, "/api/status") chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st) @@ -774,6 +775,121 @@ def run() -> None: bconn.commit() bconn.close() + # ---------------- 9. 对外暴露面(安全与隐私基线) ---------------- + # 这一节盯的是「直接挂到公网」时最容易漏掉的三类东西: + # ① 下载文件名 / 响应头注入 —— 用户名并不总是注册正则的产物 + # (manage.py passwd 建号、老库升级上来的名字都可能带引号或 CR/LF); + # ② 内部异常直出 —— 原始异常文本会带绝对路径与 SQL 片段,是踩点的现成材料; + # ③ 读接口没有刹车 —— 一个注册账号循环调 /api/bundle 就能持续吃满 CPU。 + print("== 9. 对外暴露面:响应头收敛 / 异常不外泄 / 读接口限速 ==") + + for bad in ('a"b.csv', "a\r\nX-Evil: 1", "../../etc/passwd", "a\\b.zip", "..", + ".", " ", "", "中文\n名.csv"): + got = security.safe_filename(bad, fallback="download") + chk("⑨ safe_filename 收敛 %r" % bad, + bool(got) and not any(c in got for c in '"\\\r\n/') and ".." not in got, + "-> %r" % got) + + cd = security.content_disposition('a"b\r\nX-Evil: 1.zip') + quoted = cd.split('filename="', 1)[1].split('"', 1)[0] if 'filename="' in cd else "?" + chk("⑨ content_disposition 的 ASCII 名段无引号/换行/路径", + not any(c in quoted for c in '"\\\r\n/'), "-> %r" % cd[:80]) + chk("⑨ content_disposition 带 RFC 5987 原名段", "filename*=UTF-8''" in cd) + + # 端到端:直接造一个「非法用户名」的账号,看真实导出响应头是否仍然干净。 + # 走 SQL 插入而不是接口,正是为了模拟「老库里已经有这种名字」的现状。 + api_src = open(os.path.join(BASE, "workbuddy_portal", "web", "api.py"), + encoding="utf-8").read() + evil = 'smoke_ev"%s\\x' % _rand(4) + nconn = db.connect() + try: + nconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin," + "status,created_at) VALUES(?,?,'越权探针',0,'active',?)", + (evil, security.hash_password("Smoke-Pass1"), db.now_str())) + eid = nconn.execute("SELECT id FROM users WHERE username=?", (evil,)).fetchone()["id"] + with app.test_client() as ce: + login(ce, eid) + for path, what in (("/records/export", "按明细导出"), ("/profile/export", "个人数据包")): + h = ce.get(path).headers.get("Content-Disposition", "") + chk("⑨ %s 响应头已收敛" % what, + bool(h) and "\r" not in h and "\n" not in h and 'filename="' in h, + "-> %r" % h[:80]) + finally: + nconn.execute("DELETE FROM users WHERE username=?", (evil,)) + nconn.commit() + nconn.close() + + # ② 接口层的兜底 catch 不能再把原始异常文本回给客户端。 + # 注意断言的是「兜底分支」而不是「所有 str(e)」:BadParam / Busy / + # NotReady / ApiError / BackupError 这些都是**面向用户写的**业务异常, + # 文案本身就是要给用户看的,把它们也禁掉是过度收紧。 + # 真正会带出绝对路径与 SQL 的只有 `except Exception` 那一条路。 + generic = re.findall(r"except Exception as e:[^\n]*\n([^\n]*)", api_src) + chk("⑨ 每个 except Exception 都走 _internal(不回原始异常文本)", + bool(generic) and all("_internal(" in g for g in generic), + "n=%d" % len(generic)) + + # ③ 读接口限速:把阈值临时调小,验机制本身(真阈值在 security._API_RATE_MAX) + chk("⑨ 读接口阈值给得足够宽松(不会误伤正常翻页)", + security._API_RATE_MAX >= 60, "max=%d/60s" % security._API_RATE_MAX) + old_max = security._API_RATE_MAX + try: + security._API_RATE_MAX = 5 + security.api_rate_reset() + with app.test_client() as cr: + login(cr, ADMIN) + codes = [page(cr, "/api/status")[0] for _ in range(6)] + chk("⑨ 读接口限速:前 5 次放行", codes[:5] == [200] * 5, "codes=%s" % codes) + chk("⑨ 读接口限速:第 6 次 429", codes[5] == 429, "codes=%s" % codes) + finally: + security._API_RATE_MAX = old_max + security.api_rate_reset() + + # ④ 安全响应头:对外部署时这些是扫描器与浏览器共同依赖的基线 + with app.test_client() as ch_: + h = ch_.get("/login").headers + for name in ("Content-Security-Policy", "Permissions-Policy", + "X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy"): + chk("⑨ 响应头 %s 已下发" % name, name in h) + chk("⑨ 会话 Cookie 标了 HttpOnly", "HttpOnly" in h.get("Set-Cookie", "")) + + # ⑥ 内部实现细节不下发给普通账号:表名、库文件路径这些是踩点材料, + # 普通账号看自己的数据,没有任何理由知道正本落在哪、叫什么表。 + probe = "smoke_p_%s" % _rand() + nconn2 = db.connect() + try: + nconn2.execute("INSERT INTO users(username,password_hash,display_name,is_admin," + "status,created_at) VALUES(?,?,'隐私探针',0,'active',?)", + (probe, security.hash_password("Smoke-Pass1"), db.now_str())) + pid = nconn2.execute("SELECT id FROM users WHERE username=?", + (probe,)).fetchone()["id"] + with app.test_client() as cu: + login(cu, pid) + ju = json.loads(page(cu, "/api/manifest")[1]) + chk("⑨ 普通账号拿不到「数据源」清单", ju.get("sources") == [], + "sources=%s" % ju.get("sources")) + chk("⑨ 普通账号拿不到库文件路径", not ju.get("archive"), + "archive=%r" % ju.get("archive")) + chk("⑨ 但仍能拿到自己的存档总量(不是把整块砍掉)", + ju.get("totals", {}).get("records") == 0, "records=%s" + % ju.get("totals", {}).get("records")) + with app.test_client() as ca: + login(ca, ADMIN) + ja = json.loads(page(ca, "/api/manifest")[1]) + chk("⑨ 管理员仍能看到「数据源」清单", bool(ja.get("sources")), + "n=%d" % len(ja.get("sources") or [])) + finally: + nconn2.execute("DELETE FROM users WHERE username=?", (probe,)) + nconn2.commit() + nconn2.close() + + # ⑤ 大屏页的数据插值必须全部转义(这里只钉住这次修掉的那几处, + # 它们是「模型名 / 客户端名来自上游接口」这条链路上最短的几根引线) + dash = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "dashboard", + "index.html"), encoding="utf-8").read() + for probe in ("${p.name}", "${d.name}", "${x.model}", "${x.client}"): + chk("⑨ 大屏页已转义 %s" % probe, probe not in dash) + def main() -> int: print("工程目录:%s\n" % BASE) diff --git a/workbuddy_portal/__init__.py b/workbuddy_portal/__init__.py index 86f81d4..4bd5602 100644 --- a/workbuddy_portal/__init__.py +++ b/workbuddy_portal/__init__.py @@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request from . import config, db, security -__version__ = "1.4.0" +__version__ = "1.5.0" PROJECT_NAME = config.PROJECT_NAME @@ -44,6 +44,10 @@ def _setup_logging(app): def create_app(start_scheduler=True, do_init_db=True, **overrides): + # 必须在任何写盘动作之前:把进程 umask 收到 0077,此后新建的 SQLite 库、 + # -wal / -shm、导出 CSV、备份 zip、日志文件一律是 owner-only。 + # 数据目录里躺着对话正文与主密钥,对外部署时同机其它用户不该读得到。 + config.harden_process() app = Flask(__name__, template_folder="web/templates", static_folder="web/static", @@ -100,6 +104,19 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides): return jsonify({"ok": False, "error": "forbidden", "message": "没有权限"}), 403 return render_template("error.html", code=403, message="没有权限"), 403 + @app.errorhandler(413) + def _413(e): + """请求体过大(MAX_CONTENT_LENGTH)。 + + 不加这一层的话 Flask 会吐一个默认 HTML 页,而接口调用方拿到的是 + HTML 而不是 JSON —— 前端 `r.json()` 会抛异常,最后在界面上表现成 + 「请求失败:Unexpected token <」。这里统一成与其它错误一致的形状。 + """ + msg = "请求体过大(上限 %d MB)" % (app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)) + if request.path.startswith("/api/"): + return jsonify({"ok": False, "error": "too_large", "message": msg}), 413 + return render_template("error.html", code=413, message=msg), 413 + @app.errorhandler(500) def _500(e): app.logger.exception("内部错误") diff --git a/workbuddy_portal/backup.py b/workbuddy_portal/backup.py index 8bf049b..5e9ac9a 100644 --- a/workbuddy_portal/backup.py +++ b/workbuddy_portal/backup.py @@ -231,6 +231,9 @@ def create(conn, trigger="manual", actor=None, note=""): finally: shutil.rmtree(tmpdir, ignore_errors=True) + # 归档里是「全库 + 主密钥」,权限等同管理员口令,落盘后立刻收紧。 + # (进程 umask 已收到 0077,这里额外兜住「同名文件早已存在」的情况。) + config.harden_file(dest) size = os.path.getsize(dest) digest = _sha256(dest) db.audit(conn, "backup_create", actor or "system", diff --git a/workbuddy_portal/collect.py b/workbuddy_portal/collect.py index cb6afc2..1efedbf 100644 --- a/workbuddy_portal/collect.py +++ b/workbuddy_portal/collect.py @@ -21,6 +21,7 @@ """ import csv import os +import re import time from datetime import datetime, timedelta @@ -484,11 +485,20 @@ def export_csv(conn, uid, path=None, username=None): 文件名带账号名:多用户下所有人导出到同一个目录, 不带归属就会互相覆盖。 + + 账号名必须收敛后才能拼进路径:`manage.py passwd` 建号时**不校验**用户名, + 老库升级上来的名字也可能带 `/` 或 `..`。直接拼接就是路径穿越 —— + 一个叫 `..\\..\\x` 的账号能把 CSV 写到数据目录之外。 + 这里只保留 [A-Za-z0-9._-],结果固定落在 EXPORT_DIR 之下。 + + 另:导出文件里是**对话正文的明文**,所以落盘后立刻收紧权限 + (进程 umask 已经收到 0077,这里是针对「文件早已存在、权限是旧的」那种情况)。 """ if path is None: - tag = username or ("u%s" % uid) + tag = re.sub(r"[^A-Za-z0-9._-]", "_", str(username or ""))[:48].strip("._-") + tag = tag or ("u%s" % uid) path = os.path.join(config.EXPORT_DIR, "usage_records_%s.csv" % tag) - os.makedirs(os.path.dirname(path), exist_ok=True) + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records " "WHERE user_id=? ORDER BY ts, request_id", (uid,)) n = 0 @@ -499,6 +509,7 @@ def export_csv(conn, uid, path=None, username=None): w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "", r["model"], r["client"], r["ts"]]) n += 1 + config.harden_file(path) return path, n diff --git a/workbuddy_portal/config.py b/workbuddy_portal/config.py index 15b3c2b..37c5a31 100644 --- a/workbuddy_portal/config.py +++ b/workbuddy_portal/config.py @@ -341,9 +341,58 @@ PROFILE_EMAIL_MAX = 128 # 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。 +# ---------------- 落盘权限(对外部署的隐私基线)---------------- +# 数据目录里躺着的东西比想象中敏感: +# * usage_records.prompt —— 用户与 AI 的完整对话正文 +# * settings.value —— 凭证密文;instance.json —— 拆密文的主密钥 +# * exports/*.csv —— 上面那份对话正文的**明文**副本 +# * backups/*.zip —— 「全库 + 主密钥」的压缩包 +# 默认 umask 022 会把它们留成 0644 / 0755,也就是**同机任何用户都能读**。 +# 在对外提供服务的机器上,这不是理论风险(多租户主机、共享 CI runner、 +# 被入侵的低权限进程都是现实场景)。 +# +# 处理方式分两层: +# 1. `harden_process()` 把进程 umask 收到 0077 —— 此后本进程新建的**一切** +# 文件都天然是 owner-only(SQLite 的 -wal/-shm 也包括在内)。 +# 刻意选择「一条设置管全部」而不是逐个 chmod:逐点 chmod 一定会漏, +# 而漏掉的那个文件里往往正好是最新的对话正文。 +# 2. `harden_dir()` / `harden_file()` 用于收紧**已经存在**的目录与文件 +# (老版本留下的 0755 目录、手工拷进来的备份等)。 +# Windows 没有 umask 这一套(权限走 ACL),两个函数直接返回。 +def harden_process(): + """把进程 umask 收紧到 0077。必须在任何写盘动作之前调用。""" + if os.name != "posix": + return + try: + os.umask(0o077) + except OSError: + pass + + +def harden_dir(path, mode=0o700): + """把目录权限收紧到 owner-only。失败不致命(只读 / 网络文件系统)。""" + if os.name != "posix" or not path: + return + try: + os.chmod(path, mode) + except OSError: + pass + + +def harden_file(path, mode=0o600): + """把文件权限收紧到 owner-only。渲染/导出/备份落盘后调用。""" + if os.name != "posix" or not path: + return + try: + os.chmod(path, mode) + except OSError: + pass + + def ensure_dirs(): for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR): os.makedirs(d, exist_ok=True) + harden_dir(d) def _instance_read(): diff --git a/workbuddy_portal/query.py b/workbuddy_portal/query.py index 0622856..afdf046 100644 --- a/workbuddy_portal/query.py +++ b/workbuddy_portal/query.py @@ -259,8 +259,14 @@ def day_list(conn, uid=0): "SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day", (uid or 0,))] -def manifest(conn, uid): - """该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。""" +def manifest(conn, uid, sources=True): + """该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。 + + `sources=False` 时不下发「数据源」清单(表名、库文件名这类内部实现细节)。 + 对外提供服务后,普通注册用户没有任何理由知道正本叫什么表、库文件在哪 —— + 这些是踩点阶段可以直接用的材料,属于「能不给就不给」的那一类。 + 大屏页的「数据源」卡片会因此对非管理员留空,这是预期行为。 + """ t = totals(conn, uid) db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \ conn.execute("PRAGMA page_size").fetchone()[0] @@ -272,10 +278,13 @@ def manifest(conn, uid): return { "schema": SCHEMA_VERSION, "generated": db.now_str(), - "archive": "data/usage.sqlite", - "producer": "workbuddy-portal(Flask + SQLite)", - "note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果," - "且只统计当前登录账号的归属数据。", + # archive / producer / note 只在管理员侧有意义(普通用户看的是自己的数据, + # 不需要知道正本落在哪个文件、由什么进程产出)。 + "archive": "data/usage.sqlite" if sources else "", + "producer": "workbuddy-portal(Flask + SQLite)" if sources else "", + "note": ("数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果," + "且只统计当前登录账号的归属数据。") if sources + else "daily/dims/top 均为 SQL 实时聚合结果,且只统计当前登录账号的归属数据。", "totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"], "freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"], "days": day_list(conn, uid), "months": mons, @@ -285,7 +294,7 @@ def manifest(conn, uid): "SELECT COALESCE(MAX(credits),0) FROM usage_records WHERE user_id=?", (uid,)).fetchone()[0] or 0.0)}, "months": mons, - "sources": [ + "sources": ([ {"path": "usage_records", "role": "明细正本(SQLite 表,按账号隔离)", "count": t["records"], "bytes": db_bytes}, {"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)", @@ -295,7 +304,7 @@ def manifest(conn, uid): {"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)", "count": DEFAULT_TOP_N, "bytes": 0}, {"path": "collect_runs", "role": "采集运行历史(本账号)", "count": runs, "bytes": 0}, - ], + ] if sources else []), "focusDay": (last_run["win_to"] or "")[:10] if last_run else "", "health": {"cookie": cred["set"] and not cred["broken"], "cookieChars": cred["chars"], @@ -306,7 +315,7 @@ def manifest(conn, uid): def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140, - records_cap=BUNDLE_RECORDS_CAP): + records_cap=BUNDLE_RECORDS_CAP, sources=True): """大屏页一次请求拿齐所需数据(全部限定在当前账号内)。 窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。 @@ -314,6 +323,8 @@ def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140, records 有上限(records_cap)并在响应里标注 recordsTruncated, 避免存档长大后「全部」区间把整包明细都压到浏览器。 + + `sources=False` 时不下发内部实现清单(表名 / 库文件),见 `manifest()`。 """ frm, to = norm_window(frm, to) tot = totals(conn, uid, frm, to) @@ -322,7 +333,7 @@ def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140, recs = records(conn, uid, frm, to, excerpt=excerpt, limit=records_cap if truncated else 0, newest_first=truncated) return { - "manifest": manifest(conn, uid), + "manifest": manifest(conn, uid, sources=sources), "daily": daily(conn, uid), # 全量逐日(体量小,供日历与日期轴) "dims": dims(conn, uid, frm, to), # 窗口内维度 "top": top(conn, uid, None, None, top_n)["items"], # 该账号全局 TOP 榜 diff --git a/workbuddy_portal/security.py b/workbuddy_portal/security.py index 2e16519..a1108fd 100644 --- a/workbuddy_portal/security.py +++ b/workbuddy_portal/security.py @@ -31,6 +31,7 @@ import re import secrets import sqlite3 import time +import urllib.parse from flask import (current_app, flash, g, jsonify, redirect, render_template, request, session, url_for) @@ -253,6 +254,67 @@ def action_wait_left(key): return max(0, int((_actions.get(key) or 0) - time.time())) +# ---------------- 通用读接口限速(对外部署的刹车) ---------------- +# 登录有 IP 锁定、注册有配额 + 验证码、采集有最小间隔 —— 但**读接口没有刹车**。 +# 对外提供服务后,一个注册账号循环调 /api/bundle(它要算全量逐日聚合,还会 +# 下发最多 2 万条明细)就能持续吃掉 CPU 与出口带宽;这不需要任何漏洞, +# 只要把浏览器 F12 里的那个请求放进 for 循环。 +# 所以这里按「账号(未登录时按来源 IP)」加一个滑动窗口上限。 +# 阈值刻意给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到它。 +_API_RATE_MAX = 240 # 每窗口允许的请求数 +_API_RATE_WINDOW = 60 # 窗口长度(秒) +_API_HITS_MAX_KEYS = 8192 # 字典上限,防止海量来源把内存撑爆 +_api_hits = {} # key -> [count, window_started_at] + + +def api_rate_ok(key): + """返回 (是否放行, 还需等待秒数)。""" + now = time.time() + if len(_api_hits) > _API_HITS_MAX_KEYS: + _api_hits.clear() + cur = _api_hits.get(key) + if cur is None or now - cur[1] > _API_RATE_WINDOW: + _api_hits[key] = [1, now] + return True, 0 + cur[0] += 1 + if cur[0] > _API_RATE_MAX: + return False, int(_API_RATE_WINDOW - (now - cur[1])) + 1 + return True, 0 + + +def api_rate_reset(): + """清空读接口计数(测试与排障用)。""" + _api_hits.clear() + + +# ---------------- 下载文件名 / 响应头 ---------------- +# 导出文件名里含用户名,而用户名**并不总是**注册接口那条正则的产物: +# `manage.py passwd` 建号时不做校验,老库升级上来的名字也可能带各种字符。 +# 一旦名字里有引号或 CR/LF,直接拼进响应头就是**响应头注入**(响应拆分): +# 引号之后的内容会被下游解析成新的头。所以这里一律先收敛。 +_UNSAFE_FN = re.compile(r"[^A-Za-z0-9._-]+") + + +def safe_filename(name, fallback="download", maxlen=64): + """把任意字符串收敛成可安全放进头部 / 文件系统的 ASCII 名。""" + s = _UNSAFE_FN.sub("_", str(name or "")).replace("..", "_").strip("._-") + s = s[:maxlen].strip("._-") + return s or fallback + + +def content_disposition(filename, fallback="download"): + """构造安全的 Content-Disposition。 + + 两段都给: + * `filename=` —— 纯 ASCII 收敛名,任何客户端都认,且不可能含引号/换行 + * `filename*=` —— RFC 5987 的 UTF-8 原名,让中文名在浏览器里仍然好看 + 只用后者会有老客户端兼容问题,只用前者会把中文名变成一串下划线。 + """ + raw = str(filename or "").replace("\\", "/").split("/")[-1].strip() or fallback + return 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % ( + safe_filename(raw, fallback=fallback), urllib.parse.quote(raw, safe="")) + + # ---------------- 口令 / 用户名策略 ---------------- _USERNAME_RE = re.compile(config.USERNAME_RE) @@ -313,6 +375,20 @@ _check_password = password_problem # ---------------- 登录 ---------------- +# 口令校验的**哑哈希**:用户名不存在时也走一次同代价的 PBKDF2。 +# 不补这一步的话,「账号不存在」会比「口令错误」快一到两个数量级 +# (前者根本不做哈希计算),攻击者拿一个秒表就能枚举出哪些用户名真实存在。 +# 对外开放时用户名枚举通常是撞库的第一步,所以两条路径的耗时必须对齐。 +_DUMMY_HASH = None + + +def _dummy_verify(password): + global _DUMMY_HASH + if _DUMMY_HASH is None: + _DUMMY_HASH = hash_password(secrets.token_urlsafe(16)) + verify_password(_DUMMY_HASH, password) + + def login_ok(conn, username, password): """校验口令。返回 (user_row, error_message)。 @@ -321,7 +397,10 @@ def login_ok(conn, username, password): """ username = (username or "").strip() row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() - if row is None or not verify_password(row["password_hash"], password): + if row is None: + _dummy_verify(password) + return None, "用户名或密码不正确" + if not verify_password(row["password_hash"], password): return None, "用户名或密码不正确" if (row["status"] or "active") != "active": return None, "该账号已被停用,请联系管理员" @@ -573,6 +652,11 @@ def apply_security_headers(resp): resp.headers.setdefault("Referrer-Policy", "same-origin") resp.headers.setdefault("Content-Security-Policy", CSP) resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + # 本项目不需要任何浏览器外设能力,显式关掉:缩小「被内嵌页面 / 被第三方 + # 脚本滥用」时的可用面,同时也是对外部署时安全扫描的基本要求。 + resp.headers.setdefault( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=(), payment=(), usb=()") # HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它, # 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。 # 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。 @@ -615,6 +699,16 @@ def init_app(app): @app.before_request def _guard(): g.wb_t0 = time.time() + # 读接口限速放在最前面:被限流的请求不该再走后面那些更贵的事情 + # (HTTPS 判断、CSRF、会话校验)。 + # 键用 session 里的 uid(不做回查),未登录时退化成来源 IP —— + # 这里只是限速,不承担鉴权职责,所以不需要一个「可信」的身份。 + if request.path.startswith("/api/"): + who = session.get("uid") or ("ip:" + client_ip()) + ok, wait = api_rate_ok("api:%s" % who) + if not ok: + return jsonify({"ok": False, "error": "rate_limited", + "message": "请求过于频繁,请 %d 秒后再试" % wait}), 429 if needs_https_redirect(): url = request.url.replace("http://", "https://", 1) return redirect(url, code=301) diff --git a/workbuddy_portal/web/api.py b/workbuddy_portal/web/api.py index 08c6a80..862366e 100644 --- a/workbuddy_portal/web/api.py +++ b/workbuddy_portal/web/api.py @@ -20,7 +20,9 @@ * 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写, 任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。 """ +import logging import os +import secrets from datetime import datetime from flask import Blueprint, jsonify, request, send_file, session @@ -29,6 +31,7 @@ from .. import backup, collect, config, db, query, scheduler, security from ..security import admin_required, current_user, is_admin, login_required bp = Blueprint("api", __name__, url_prefix="/api") +log = logging.getLogger("wb.api") def _uid(): @@ -36,6 +39,23 @@ def _uid(): return u["id"] if u else 0 +def _internal(e, where): + """内部异常的**唯一出口**:细节只进日志,响应只给一个可对账的事件号。 + + 以前这里是 `jsonify({"message": str(e)})` —— 把原始异常文本原样回给客户端。 + 那等于把服务端内部结构送给调用方:绝对路径(`/app/workbuddy_portal/...`)、 + SQL 语句、`sqlite3` 的错误、备份目录位置都可能出现在里面。 + 对一个对外开放的站点来说,这些正好是踩点阶段最想要的信息。 + 现在改成:完整堆栈写进服务端日志(带事件号),客户端只拿到事件号 —— + 既不影响「出事了去查日志」的运维动作,也不泄露任何内部细节。 + """ + eid = secrets.token_hex(4) + log.exception("[%s] %s 处理失败", eid, where) + return jsonify({"ok": False, "error": "internal", + "message": "服务器内部错误,请稍后重试" + "(事件号 %s,管理员可在「日志管理」中检索)" % eid}), 500 + + def _ip(): """客户端地址。一律走 security.client_ip() —— 见那里的注释: 直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。""" @@ -87,7 +107,9 @@ def _int(name, default, lo=1, hi=2000): @login_required def api_manifest(): u = current_user() - m = query.manifest(db.get_db(), u["id"]) + # 数据源清单(表名 / 库文件)属于内部实现细节,只给管理员; + # 普通账号看自己的数据,没有理由知道正本落在哪个文件、叫什么表。 + m = query.manifest(db.get_db(), u["id"], sources=u["is_admin"]) # 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文, # 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。 # 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。 @@ -101,7 +123,8 @@ def api_bundle(): """大屏页一次拿齐:全量 daily + 窗口 dims/top/records。""" frm, to = _win() return jsonify(query.bundle(db.get_db(), _uid(), frm, to, - top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000))) + top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000), + sources=is_admin())) @bp.get("/summary") @@ -319,7 +342,7 @@ def api_collect(): return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api", "message": str(e)}), code except Exception as e: # noqa: BLE001 - return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 + return _internal(e, "POST /api/collect uid=%s" % uid) db.audit(conn, "collect", u["username"], r["message"], _ip(), uid) return jsonify({"ok": True, "result": r}) @@ -407,7 +430,7 @@ def api_maintenance(action): else: return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404 except Exception as e: # noqa: BLE001 - return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 + return _internal(e, "POST /api/maintenance/%s uid=%s" % (action, uid)) db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid) return jsonify({"ok": True, "message": msg}) @@ -449,7 +472,7 @@ def api_backup_create(): 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 _internal(e, "POST /api/backups") removed = backup.prune(conn, actor=u["username"]) if removed: r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed) @@ -495,7 +518,7 @@ def api_backup_restore(filename): 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 _internal(e, "POST /api/backups/restore") return jsonify(r) diff --git a/workbuddy_portal/web/static/css/app.css b/workbuddy_portal/web/static/css/app.css index 4b8ce66..821aa5b 100644 --- a/workbuddy_portal/web/static/css/app.css +++ b/workbuddy_portal/web/static/css/app.css @@ -1,41 +1,49 @@ /* ============================================================ WorkBuddy Portal —— 统一视觉系统 - 设计令牌与大屏页(static/dashboard/index.html)保持一致, - 两处用同一套色板/圆角/间距,避免后台与大屏像两个产品。 + 风格取向:工程控制台。中性灰阶打底,单一强调色(蓝), + 不用渐变、不用辉光、不做彩色装饰条 —— 信息本身是唯一的主角。 + 设计令牌与大屏页(static/dashboard/index.html)共用一套, + 两处颜色/圆角/间距保持一致,避免后台与大屏像两个产品。 + + 约定:模板只使用类名,不依赖任何渐变或阴影细节; + 因此调整本文件不需要改动任何 Jinja 模板。 ============================================================ */ :root { /* 底色与层次 */ - --bg: #080d1a; - --panel: rgba(255, 255, 255, .045); - --panel-hi: rgba(255, 255, 255, .07); - --panel-dim: rgba(0, 0, 0, .30); - --line: rgba(255, 255, 255, .09); - --line-soft: rgba(255, 255, 255, .06); + --bg: #0e1013; /* 页面底 */ + --bg-elev: #14171b; /* 顶栏 / 表头等抬升面 */ + --panel: #16191e; /* 卡片 */ + --panel-hi: #1d2128; /* 卡片内交互面(按钮、分段控件) */ + --panel-dim: #101318; /* 输入框 / 代码块 */ + + --line: #262b33; /* 常规分隔线 */ + --line-soft: #1f242b; /* 表格行分隔线 */ /* 文本 */ - --text: #e8edf7; - --sub: #8b9bb4; - --dim: #64748b; + --text: #e4e7eb; + --sub: #98a1ac; + --dim: #6b7480; - /* 主色板(与大屏 ECharts PALETTE 同源) */ - --cyan: #22d3ee; - --violet: #a78bfa; - --amber: #fbbf24; - --green: #34d399; - --red: #f87171; - --blue: #60a5fa; - --pink: #f472b6; + /* 强调色与语义色(低饱和,只做状态区分,不做视觉噪音) */ + --accent: #4c8df6; + --accent-dim: rgba(76, 141, 246, .14); + --cyan: #4aa8c0; + --violet: #8b7ec8; + --amber: #c9952f; + --green: #4ba97b; + --red: #cf6679; + --blue: #4c8df6; + --pink: #c07a9e; - /* 语义色(用量口径:升用琥珀、降用青,刻意不用红绿) */ + /* 语义色(用量口径:升用琥珀、降用蓝,刻意不用红绿) */ --up: var(--amber); - --down: var(--cyan); + --down: var(--accent); /* 形状与节奏 */ - --r-card: 14px; - --r-ctl: 9px; - --r-pill: 6px; + --r-card: 8px; + --r-ctl: 6px; + --r-pill: 4px; --gap: 16px; - --shadow: 0 12px 34px rgba(0, 0, 0, .42); color-scheme: dark; } @@ -44,10 +52,7 @@ html, body { margin: 0; padding: 0; } body { - background: - radial-gradient(1100px 600px at 12% -10%, rgba(34, 211, 238, .10), transparent 60%), - radial-gradient(900px 500px at 100% 0%, rgba(167, 139, 250, .10), transparent 55%), - var(--bg); + background: var(--bg); color: var(--text); font: 13.5px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; @@ -55,7 +60,7 @@ body { -webkit-font-smoothing: antialiased; } -a { color: var(--cyan); text-decoration: none; transition: color .15s; } +a { color: var(--accent); text-decoration: none; transition: color .15s; } a:hover { text-decoration: underline; } /* 表单控件必须显式继承字体:曾经写过 `font: 13px/1.5 inherit`, 那是非法声明(简写里不能出现 inherit 作为字族),整条被浏览器丢弃, @@ -63,9 +68,9 @@ a:hover { text-decoration: underline; } button, input, select, textarea { font-family: inherit; font-size: 13px; } code { - background: rgba(255, 255, 255, .07); padding: 1px 5px; border-radius: 4px; + background: var(--panel-hi); padding: 1px 5px; border-radius: 3px; font: 12px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace; - color: var(--cyan); + color: var(--text); } .mono, .logbox, .fullprompt { font-family: ui-monospace, Consolas, "Cascadia Mono", monospace; @@ -77,47 +82,43 @@ code { .muted { color: var(--sub); } .right { text-align: right; } /* 页面标题里嵌的关键数字(如总积分),给一点强调但不喧宾夺主 */ -.hl { color: var(--cyan); font-variant-numeric: tabular-nums; } +.hl { color: var(--accent); font-variant-numeric: tabular-nums; } /* 0 积分的记录整行数字变暗,扫一眼就能跳过长尾 */ .tbl td.zero { color: var(--dim); } /* 可访问性:键盘焦点一定要看得见 */ -:focus-visible { outline: 2px solid rgba(34, 211, 238, .65); outline-offset: 2px; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* ---------------- 顶栏 ---------------- */ .topbar { display: flex; align-items: center; gap: 18px; - padding: 0 22px; height: 58px; - background: rgba(10, 16, 28, .86); + padding: 0 22px; height: 56px; + background: var(--bg-elev); border-bottom: 1px solid var(--line); - backdrop-filter: blur(10px); position: sticky; top: 0; z-index: 50; } -.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; letter-spacing: .2px; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; } .brand a { color: var(--text); } -.brand a:hover { text-decoration: none; color: var(--cyan); } +.brand a:hover { text-decoration: none; color: var(--accent); } .brand .dot { - width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); - box-shadow: 0 0 12px var(--cyan); flex: 0 0 auto; + width: 8px; height: 8px; border-radius: 2px; background: var(--accent); + flex: 0 0 auto; } .brand .ver { color: var(--dim); font-size: 11px; } -.topbar nav { display: flex; gap: 3px; margin-left: 6px; overflow-x: auto; } +.topbar nav { display: flex; gap: 2px; margin-left: 6px; overflow-x: auto; } .topbar nav a { - padding: 6px 13px; border-radius: var(--r-ctl); color: var(--sub); - font-size: 13px; white-space: nowrap; position: relative; + padding: 6px 12px; border-radius: var(--r-ctl); color: var(--sub); + font-size: 13px; white-space: nowrap; } .topbar nav a:hover { background: var(--panel-hi); color: var(--text); text-decoration: none; } -.topbar nav a.on { - background: linear-gradient(135deg, rgba(34, 211, 238, .22), rgba(167, 139, 250, .22)); - color: #fff; font-weight: 600; -} +.topbar nav a.on { background: var(--accent-dim); color: var(--text); font-weight: 600; } .topbar .me { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; } .topbar .who { color: var(--sub); font-size: 12.5px; } .topbar .who b { color: var(--text); font-weight: 600; } /* 用户名同时是「个人中心」入口,所以是 :去掉下划线并给悬浮反馈 */ .topbar .who { display: flex; align-items: center; gap: 6px; text-decoration: none; } -.topbar .who:hover, .topbar .who:hover b { color: var(--cyan); } +.topbar .who:hover, .topbar .who:hover b { color: var(--accent); } /* ---------------- 布局 ---------------- */ .wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; } @@ -126,14 +127,9 @@ code { gap: 14px; flex-wrap: wrap; margin-bottom: var(--gap); } .pagehead h1 { - font-size: 20px; margin: 0; font-weight: 700; letter-spacing: .3px; - display: flex; align-items: center; gap: 10px; + font-size: 19px; margin: 0; font-weight: 600; color: var(--text); } -.pagehead h1::before { - content: ""; width: 4px; height: 19px; border-radius: 2px; - background: linear-gradient(180deg, var(--cyan), var(--violet)); flex: 0 0 auto; -} -.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 4px 0 0 14px; } +.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 5px 0 0; } .pagehead .actions { display: flex; gap: 10px; flex-wrap: wrap; } .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); } .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--gap); margin-bottom: var(--gap); } @@ -145,14 +141,9 @@ code { padding: 16px 18px; margin-bottom: var(--gap); } .card > h2, .card > .cardhead > h2 { - font-size: 14px; margin: 0 0 12px; font-weight: 600; letter-spacing: .2px; - display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + font-size: 13.5px; margin: 0 0 12px; font-weight: 600; color: var(--text); } .card > .cardhead > h2 { margin-bottom: 0; } -.card > h2::before, .card > .cardhead > h2::before { - content: ""; width: 3px; height: 13px; border-radius: 2px; - background: var(--cyan); flex: 0 0 auto; -} .cardhead { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; @@ -161,7 +152,7 @@ code { .card h3 { font-size: 13px; margin: 18px 0 10px; font-weight: 600; color: var(--text); } .card h3:first-child { margin-top: 0; } .hint { color: var(--sub); font-size: 12px; line-height: 1.75; } -.hint a { text-decoration: underline; text-decoration-color: rgba(34, 211, 238, .4); } +.hint a { text-decoration: underline; } .card > .hint:last-child { margin-bottom: 0; } .sect-divider { border: 0; border-top: 1px solid var(--line); margin: 18px 0; } @@ -172,18 +163,21 @@ code { } .kpi { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-card); - padding: 14px 16px; position: relative; overflow: hidden; + padding: 14px 16px; } -.kpi::after { - content: ""; position: absolute; left: 0; top: 0; width: 3px; height: 100%; - background: var(--c, var(--cyan)); +/* --c 由模板行内给出:只作为一个 8px 的状态点,不做整块着色 */ +.kpi > span { + color: var(--sub); font-size: 12px; display: flex; align-items: center; gap: 6px; +} +.kpi > span::before { + content: ""; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; + background: var(--c, var(--line)); } -.kpi > span { color: var(--sub); font-size: 12px; display: block; letter-spacing: .2px; } .kpi > b { - display: block; font-size: 23px; margin: 7px 0 4px; font-weight: 700; - font-variant-numeric: tabular-nums; letter-spacing: .3px; + display: block; font-size: 22px; margin: 8px 0 4px; font-weight: 600; + font-variant-numeric: tabular-nums; } -.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; } +.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; line-height: 1.6; } .kpi > i .delta { color: var(--up); font-variant-numeric: tabular-nums; } .kpi > i .delta.dn { color: var(--down); } @@ -191,7 +185,7 @@ code { /* 表格必须包在 .tablewrap 里:窄屏时横向滚动,而不是把卡片撑破 */ .tablewrap { overflow-x: auto; margin: 0 -2px; } .tablewrap::-webkit-scrollbar { height: 8px; } -.tablewrap::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .14); border-radius: 4px; } +.tablewrap::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; } .tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; } .tbl th, .tbl td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--line-soft); @@ -199,18 +193,17 @@ code { } .tbl th { color: var(--sub); font-weight: 500; font-size: 11.5px; - white-space: nowrap; letter-spacing: .3px; - position: sticky; top: 0; background: rgba(12, 18, 32, .96); - backdrop-filter: blur(4px); z-index: 1; + white-space: nowrap; + position: sticky; top: 0; background: var(--bg-elev); z-index: 1; } -.tbl tbody tr:hover { background: rgba(255, 255, 255, .028); } +.tbl tbody tr:hover { background: var(--panel-hi); } .tbl tbody tr:last-child td { border-bottom: 0; } .tbl td.num, .tbl th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; } +.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; background: none; } .tbl td.px { max-width: 460px; min-width: 220px; } .tbl details summary { cursor: pointer; color: var(--sub); } .tbl details summary:hover { color: var(--text); } -.tbl details[open] summary { color: var(--cyan); } +.tbl details[open] summary { color: var(--text); } .scroll-y { max-height: 340px; overflow: auto; } @@ -223,7 +216,7 @@ code { .fullprompt { margin-top: 9px; padding: 11px 13px; background: var(--panel-dim); - border: 1px solid var(--line); border-radius: 8px; + border: 1px solid var(--line); border-radius: var(--r-ctl); white-space: pre-wrap; word-break: break-word; color: var(--text); font-size: 12px; line-height: 1.75; max-height: 320px; overflow: auto; } @@ -232,14 +225,14 @@ code { .tag { display: inline-block; padding: 1px 7px; border-radius: var(--r-pill); font-size: 11px; line-height: 17px; white-space: nowrap; - background: rgba(255, 255, 255, .08); color: var(--sub); vertical-align: 1px; + background: var(--panel-hi); color: var(--sub); vertical-align: 1px; } -.tag.ok { background: rgba(52, 211, 153, .16); color: var(--green); } -.tag.warn { background: rgba(251, 191, 36, .16); color: var(--amber); } -.tag.bad { background: rgba(248, 113, 113, .16); color: var(--red); } -.tag.info { background: rgba(96, 165, 250, .16); color: var(--blue); } -.tag.accent { background: rgba(167, 139, 250, .18); color: var(--violet); } -.tag.mute { background: rgba(255, 255, 255, .06); color: var(--dim); } +.tag.ok { background: rgba(75, 169, 123, .16); color: var(--green); } +.tag.warn { background: rgba(201, 149, 47, .16); color: var(--amber); } +.tag.bad { background: rgba(207, 102, 121, .16); color: var(--red); } +.tag.info { background: rgba(76, 141, 246, .16); color: var(--blue); } +.tag.accent { background: rgba(139, 126, 200, .16); color: var(--violet); } +.tag.mute { background: var(--panel-hi); color: var(--dim); } .kbd { display: inline-block; padding: 0 6px; border-radius: 4px; @@ -252,19 +245,18 @@ code { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 14px; border-radius: var(--r-ctl); border: 1px solid var(--line); background: var(--panel-hi); color: var(--text); font-size: 12.5px; - cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s, filter .15s; + cursor: pointer; transition: background .15s, border-color .15s; white-space: nowrap; text-decoration: none; } -.btn:hover { background: rgba(255, 255, 255, .11); text-decoration: none; } +.btn:hover { background: #232830; text-decoration: none; } .btn.primary { - background: linear-gradient(135deg, rgba(34, 211, 238, .26), rgba(167, 139, 250, .26)); - border-color: rgba(34, 211, 238, .42); color: #fff; font-weight: 600; + background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } -.btn.primary:hover { border-color: var(--cyan); box-shadow: 0 0 14px rgba(34, 211, 238, .22); } +.btn.primary:hover { background: #5e99f7; border-color: #5e99f7; } .btn.ghost { background: transparent; color: var(--sub); } .btn.ghost:hover { color: var(--text); background: var(--panel-hi); } -.btn.danger { border-color: rgba(248, 113, 113, .34); color: var(--red); background: rgba(248, 113, 113, .10); } -.btn.danger:hover { background: rgba(248, 113, 113, .18); } +.btn.danger { border-color: rgba(207, 102, 121, .34); color: var(--red); background: rgba(207, 102, 121, .10); } +.btn.danger:hover { background: rgba(207, 102, 121, .18); } .btn.sm { padding: 4px 10px; font-size: 12px; } .btn[disabled], .btn[aria-disabled=true] { opacity: .5; cursor: not-allowed; } .btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } @@ -273,14 +265,12 @@ form .btn { margin-top: 8px; } /* ---------------- 表单 ---------------- */ label { display: block; color: var(--sub); font-size: 12px; } label input, label select, label textarea, .inp { - width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: 8px; + width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: var(--r-ctl); background: var(--panel-dim); border: 1px solid var(--line); color: var(--text); - font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s, background .15s; -} -label input:hover, label select:hover, label textarea:hover { border-color: rgba(255, 255, 255, .16); } -label input:focus, label select:focus, label textarea:focus { - border-color: var(--cyan); background: rgba(0, 0, 0, .42); + font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s; } +label input:hover, label select:hover, label textarea:hover { border-color: #333a44; } +label input:focus, label select:focus, label textarea:focus { border-color: var(--accent); } label textarea { font-family: ui-monospace, Consolas, "Cascadia Mono", monospace; font-size: 12px; line-height: 1.6; resize: vertical; @@ -293,7 +283,7 @@ label.row.inline { display: inline-flex; margin-bottom: 0; } label.row.inline > span { flex: 0 0 auto; } label .unit { color: var(--dim); font-size: 11.5px; margin-left: 8px; flex: 0 0 auto; } .field-err { color: var(--red); font-size: 11.5px; margin-top: 4px; } -select option { background: #111a2e; color: var(--text); } +select option { background: var(--bg-elev); color: var(--text); } .filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; } .filters label { flex: 1 1 152px; } @@ -317,23 +307,17 @@ select option { background: #111a2e; color: var(--text); } font-size: 12.5px; cursor: pointer; transition: .15s; font-family: inherit; text-decoration: none; white-space: nowrap; } -.seg a:hover, .seg button:hover { color: var(--text); background: rgba(255, 255, 255, .06); text-decoration: none; } -.seg a.on, .seg button.on { - background: linear-gradient(135deg, rgba(34, 211, 238, .28), rgba(167, 139, 250, .28)); - color: #fff; font-weight: 600; -} +.seg a:hover, .seg button:hover { color: var(--text); text-decoration: none; } +.seg a.on, .seg button.on { background: var(--accent-dim); color: var(--text); font-weight: 600; } /* ---------------- 分页 ---------------- */ .pager { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; align-items: center; } .pager a, .pager .cur, .pager .gap { - padding: 4px 11px; border-radius: 7px; border: 1px solid var(--line); + padding: 4px 11px; border-radius: var(--r-ctl); border: 1px solid var(--line); font-size: 12px; color: var(--sub); min-width: 34px; text-align: center; } -.pager a:hover { color: var(--text); border-color: rgba(34, 211, 238, .45); text-decoration: none; } -.pager .cur { - background: linear-gradient(135deg, rgba(34, 211, 238, .24), rgba(167, 139, 250, .24)); - color: #fff; border-color: transparent; font-weight: 600; -} +.pager a:hover { color: var(--text); border-color: var(--accent); text-decoration: none; } +.pager .cur { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; } .pager .gap { border-color: transparent; color: var(--dim); } .pager .meta { margin-left: auto; color: var(--sub); font-size: 11.5px; } @@ -342,18 +326,18 @@ select option { background: #111a2e; color: var(--text); } .flash { padding: 11px 14px; border-radius: var(--r-ctl); font-size: 12.5px; margin-bottom: 8px; border: 1px solid var(--line); - background: rgba(34, 211, 238, .10); color: var(--text); + background: var(--panel-hi); color: var(--text); line-height: 1.7; } -.flash.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .3); } -.flash.warn { background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .3); } -.flash.ok { background: rgba(52, 211, 153, .12); border-color: rgba(52, 211, 153, .3); } +.flash.error { background: rgba(207, 102, 121, .12); border-color: rgba(207, 102, 121, .30); } +.flash.warn { background: rgba(201, 149, 47, .12); border-color: rgba(201, 149, 47, .30); } +.flash.ok { background: rgba(75, 169, 123, .12); border-color: rgba(75, 169, 123, .30); } .flash:last-child { margin-bottom: 0; } .logbox { - background: rgba(0, 0, 0, .40); border: 1px solid var(--line); border-radius: var(--r-ctl); + background: var(--panel-dim); border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 12px 14px; max-height: 430px; overflow: auto; margin: 0; font-size: 11.5px; line-height: 1.75; white-space: pre-wrap; - word-break: break-word; color: #c6d3e6; + word-break: break-word; color: var(--sub); } .logbox:empty::before { content: "(暂无内容)"; color: var(--dim); } .empty-state { @@ -369,12 +353,12 @@ select option { background: #111a2e; color: var(--text); } } .login { width: 100%; max-width: 400px; padding: 28px 28px 22px; margin: 0; } .login .logo { - width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px; - background: linear-gradient(135deg, var(--cyan), var(--violet)); + width: 40px; height: 40px; border-radius: var(--r-ctl); margin-bottom: 14px; + background: var(--accent); display: flex; align-items: center; justify-content: center; - font-size: 20px; font-weight: 800; color: #061019; + font-size: 19px; font-weight: 700; color: #fff; } -.login h1 { font-size: 19px; margin: 0 0 5px; } +.login h1 { font-size: 19px; margin: 0 0 5px; font-weight: 600; } .login .hint { margin: 0 0 20px; } .login label { margin-bottom: 14px; } .login .btn { width: 100%; padding: 10px; margin-top: 4px; } @@ -398,14 +382,13 @@ select option { background: #111a2e; color: var(--text); } border: 1px solid var(--line); border-radius: var(--r-ctl); background: var(--panel-dim); cursor: pointer; transition: .15s; } -.capimg:hover { border-color: var(--cyan); } +.capimg:hover { border-color: var(--accent); } /* ---------------- 错误页 ---------------- */ .errpage { text-align: center; padding: 66px 24px; } .errpage .code { - font-size: 52px; font-weight: 800; line-height: 1; margin: 0 0 12px; - background: linear-gradient(135deg, var(--cyan), var(--violet)); - -webkit-background-clip: text; background-clip: text; color: transparent; + font-size: 46px; font-weight: 600; line-height: 1; margin: 0 0 14px; + color: var(--sub); font-variant-numeric: tabular-nums; } .errpage p { color: var(--sub); margin: 0 0 20px; } diff --git a/workbuddy_portal/web/static/dashboard/index.html b/workbuddy_portal/web/static/dashboard/index.html index 5c45f3b..68542d2 100644 --- a/workbuddy_portal/web/static/dashboard/index.html +++ b/workbuddy_portal/web/static/dashboard/index.html @@ -1,10 +1,10 @@ - - - - + + + + WorkBuddy Portal · 积分消耗大屏 -"> + @@ -15,135 +15,133 @@ 明细按窗口裁剪下发(且不含 prompt 全文),页面不再整包搬运整个存档。 详见页面脚本里的 loadSources() / refetch()。 --> - + -
-
-

WorkBuddy Portal · 积分消耗大屏

-
数据来源:本项目 /api(SQLite 正本 data/usage.sqlite),页面按筛选条件实时聚合
+
+ -
- 数据范围 -
- 共 - 天 · - 条请求记录
+
+ 数据范围 -
+ 共 - 天 · - 条请求记录
数据快照 -
-
+
-
-
- 日期范围 -
- - - - - +
+
+ 日期范围 +
+ + + + +
- -
-
- 统计维度 -
- - - - +
+ 统计维度 +
+ + + +
-
- 拆分维度 -
- - - +
+ 拆分维度 +
+ + +
- +
-
+
-
-
-
-

消耗日历 · 日志视图

-
按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日
+
+
+
+

消耗日历 · 日志视图

+
按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日
-
-
- 着色依据 -
- - +
+
+ 着色依据 +
+ +
- - - - - + + - + +
-
-
- 一二三四五六日 +
+
+ 一二三四五六日
-
-
+
+
-
+
-
-
-

消耗趋势

-
按选定统计维度展示积分消耗与调用次数
-
+
+
+

消耗趋势

+
按选定统计维度展示积分消耗与调用次数
+
-
+
-

维度构成

-
- - +

维度构成

+
+ +
-
按拆分维度统计积分消耗占比
-
+
按拆分维度统计积分消耗占比
+
-
-
-

维度堆叠分布

-
各维度在各时间段上的积分消耗堆叠
-
+
+
+

维度堆叠分布

+
各维度在各时间段上的积分消耗堆叠
+
-
-

时段 × 日期热力图

-
颜色越亮表示该时段消耗越高
-
+
+

时段 × 日期热力图

+
颜色越亮表示该时段消耗越高
+
-
-
-

单笔消耗分布(长尾)

-
按单笔积分分桶
-
+
+
+

单笔消耗分布(长尾)

+
按单笔积分分桶
+
-
-

模型效率散点

-
请求数 × 积分,气泡大小 = 单次均价
-
+
+

模型效率散点

+
请求数 × 积分,气泡大小 = 单次均价
+
-
-
-

维度汇总表

-
按拆分维度聚合,按积分消耗降序
-
+
+
+

维度汇总表

+
按拆分维度聚合,按积分消耗降序
+
-
-

每日明细表

-
当前筛选范围内逐日汇总
-
+
+

每日明细表

+
当前筛选范围内逐日汇总
+
-
-
+
+
-

TOP 单笔请求

-
- - +

TOP 单笔请求

+
+ +
-
按积分降序
-
+
按积分降序
+
-
-

数据源

-
数据全部由本项目 Flask 后端经 SQLite 实时聚合(无中间 JSON 层)
-
-
+
+

数据源

+
数据由本项目 Flask 后端从 SQLite 实时聚合
+
+
-
数据链:本项目 Flask 后端(进程内定时采集 → SQLite 正本)→ /api 实时聚合 → 本页渲染 · - 筛选项变更会按窗口重新取数(明细按需下发,不含 prompt 全文)
+
数据链:进程内定时采集 → data/usage.sqlite → /api 实时聚合 → 本页渲染