feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)

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

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

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

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
这个提交包含在:
2026-09-18 11:13:17 +08:00
父节点 3751dffef9
当前提交 f36149efc3
共修改 29 个文件,包含 1105 行新增和 558 行删除
+21 -10
查看文件
@@ -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 榜