文件
workbuddy-portal/workbuddy_portal/db.py
T
wangchuanli 3751dffef9 feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
2026-09-18 08:46:34 +08:00

551 行
24 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""SQLite 访问层(多用户版)。
并发约定(重要):
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
* 单写者 —— SQLite 同一时刻只允许一个写进程,所以采集必须串行
(由 scheduler / CLI 共享的 collect.lock 保证)
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
多用户约定(改代码前务必先读):
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
的代码都不可能把凭证带出去。
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
虽然时刻本身是实例级的 —— 这两个千万别一起改。
"""
import logging
import os
import secrets
import sqlite3
import threading
from datetime import datetime
from . import config, crypto
log = logging.getLogger("wb.db")
_local = threading.local()
_init_lock = threading.Lock()
_initialized = False
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
DB_SCHEMA_VERSION = 4
# 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里,
# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。
# `manage.py init` / entrypoint 负责把它打印到启动日志。
_generated_admin_password = None
def generated_admin_password():
"""本次进程启动时生成的管理员口令(没有生成过则为 None)。"""
return _generated_admin_password
class SecretUnreadable(Exception):
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def connect(path=None):
"""新建一个连接(调用方负责关闭)。"""
config.ensure_dirs()
conn = sqlite3.connect(path or config.SQLITE_PATH, timeout=8.0,
isolation_level=None) # autocommit,事务用显式 BEGIN
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=8000")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def thread_conn():
"""按线程缓存的连接(采集线程、调度线程各自一份)。"""
c = getattr(_local, "conn", None)
if c is None:
c = _local.conn = connect()
return c
def close_thread_conn():
c = getattr(_local, "conn", None)
if c is not None:
try:
c.close()
except sqlite3.Error:
pass
_local.conn = None
def _schema_sql():
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
return f.read()
# ---------------- 初始化 / 迁移 ----------------
def _table_cols(conn, table):
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
def _has_table(conn, name):
return bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
def _first_owner_uid(conn):
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
if row:
return row["id"]
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else 0
def _drop_all_user_indexes(conn):
"""删掉本项目自建的全部索引(idx_*)。
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
"""
names = [r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
for n in names:
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
return names
def _migrate(conn):
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
顺序不能变:
1. 删索引(否则改名会把索引名占住,新表建不出索引)
2. 改名主键变了的表(settings / usage_records)
3. ALTER 加列(collect_runs / audit_log / users)
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
5. 回填数据、删掉 _v1_ 旧表
"""
ver = conn.execute("PRAGMA user_version").fetchone()[0]
if ver >= DB_SCHEMA_VERSION:
return []
done = []
ts = now_str()
owner = _first_owner_uid(conn)
tset = _table_cols(conn, "settings")
tur = _table_cols(conn, "usage_records")
rebuild_settings = "user_id" not in tset
rebuild_records = "user_id" not in tur
_drop_all_user_indexes(conn)
if rebuild_settings:
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
if rebuild_records:
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
if "user_id" not in _table_cols(conn, "collect_runs"):
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
done.append("collect_runs 增加 user_id")
if "user_id" not in _table_cols(conn, "audit_log"):
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
done.append("audit_log 增加 user_id")
tusers = _table_cols(conn, "users")
for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"),
("last_login_ip", "TEXT"),
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col)
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
if rebuild_settings:
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT 0,key,value,updated_at FROM _v1_settings"
" WHERE key NOT IN ('cookie','user_agent')")
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT ?,key,value,updated_at FROM _v1_settings"
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
(owner,))
conn.execute("DROP TABLE _v1_settings")
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
"Cookie/UA 已归属账号 #%d" % owner)
if rebuild_records:
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
"credits,prompt,first_seen,last_seen,cloud_ts)"
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
conn.execute("DROP TABLE _v1_usage_records")
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
"历史数据归属账号 #%d" % owner)
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
if done:
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
return done
def _encrypt_legacy_secrets(conn):
"""把历史**明文**凭证就地加密。幂等,可反复执行。
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
误提交、排障时的一次 dump 都会把它带出去。
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
"""
if not config.ENCRYPTED_KEYS:
return []
key = config.encryption_key()
done = []
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
if r["key"] not in config.ENCRYPTED_KEYS:
continue
raw = r["value"]
if not raw or crypto.is_encrypted(raw):
continue
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
return done
def _promote_personal_to_global(conn):
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
必须做两件事,否则会静默丢配置:
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
跳过),但会让后面排障的人以为「这个键是个人级的」。
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
"""
owner = _first_owner_uid(conn)
promoted, cleaned = [], 0
for k in sorted(config.GLOBAL_KEYS):
if owner:
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(owner, k)).fetchone()
if mine is not None and mine["value"] is not None:
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(k,)).fetchone()
if cur is None or cur["value"] != mine["value"]:
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
promoted.append(k)
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
cleaned += cur2.rowcount or 0
return promoted, cleaned
def _create_first_admin(conn, admin_user, admin_password, ts):
"""建库后的第一个管理员。
口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。
这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令
等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。
生成的口令只在启动日志里打印一次,并被提示立即修改。
"""
global _generated_admin_password
from .security import hash_password
pwd = (admin_password or config.ADMIN_PASSWORD or "").strip()
generated = False
if not pwd:
pwd = secrets.token_urlsafe(12)
generated = True
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
if generated:
_generated_admin_password = pwd
# 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。
log.warning("=" * 68)
log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user)
log.warning(" 用户名:%s", admin_user)
log.warning(" 口 令:%s", pwd)
log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。")
log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。")
log.warning("=" * 68)
return generated
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized
own = conn is None
conn = conn or connect()
try:
if not _has_table(conn, "users"):
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
conn.executescript(_schema_sql())
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
migrated = []
else:
migrated = _migrate(conn)
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
conn.executescript(_schema_sql())
ts = now_str()
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
#
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
for k, v in config.DEFAULTS.items():
if k in config.USER_EDITABLE_KEYS:
continue
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
" VALUES(0,?,?,?)", (k, v, ts))
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
for k in sorted(config.USER_EDITABLE_KEYS):
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
# 顺手把历史明文凭证加密(幂等;新库无事可做)
enc = _encrypt_legacy_secrets(conn)
if enc:
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "encrypt_secrets",
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
promoted, cleaned = _promote_personal_to_global(conn)
if promoted or cleaned:
note = "配置作用域收敛:提升 %s 到实例级%s" % (
", ".join(promoted) if promoted else "(无)",
",清理 %d 条个人级残留" % cleaned if cleaned else "")
migrated.append(note)
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
_create_first_admin(conn, admin_user, admin_password, ts)
_initialized = True
return migrated
finally:
if own:
conn.close()
# ---------------- 配置读写(按作用域) ----------------
def get_setting(conn, key, default=None, uid=0):
"""取单个配置。
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
"""
if key in config.ENCRYPTED_KEYS:
return ""
uid = 0 if config.is_global_key(key) else (uid or 0)
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid, key)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
if uid and key not in NO_FALLBACK_KEYS:
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(key,)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
return config.DEFAULTS.get(key, default)
def get_settings(conn, keys=None, uid=0):
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
"""
out = dict(config.DEFAULTS)
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
out[r["key"]] = r["value"]
if uid:
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
if config.is_global_key(r["key"]):
continue # 个人作用域里不该有全局键,有也不认
out[r["key"]] = r["value"]
for k in config.ENCRYPTED_KEYS:
out[k] = ""
if keys:
return {k: out.get(k) for k in keys}
return out
def set_setting(conn, key, value, uid=0):
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
uid = 0 if config.is_global_key(key) else (uid or 0)
text = "" if value is None else str(value)
if key in config.ENCRYPTED_KEYS and text:
text = crypto.encrypt(text, config.encryption_key())
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
def set_settings(conn, pairs, uid=0):
for k, v in pairs.items():
set_setting(conn, k, v, uid)
def get_int(conn, key, default=0, uid=0):
try:
return int(float(get_setting(conn, key, default, uid)))
except (TypeError, ValueError):
return default
def get_float(conn, key, default=0.0, uid=0):
try:
return float(get_setting(conn, key, default, uid))
except (TypeError, ValueError):
return default
def get_bool(conn, key, default=False, uid=0):
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
return v in ("1", "true", "yes", "on", "是")
# ---------------- 凭证(加密存储) ----------------
def get_secret(conn, key, uid=0):
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return ""
try:
return crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError as e:
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
def set_secret(conn, key, value, uid=0):
set_setting(conn, key, value, uid)
def secret_state(conn, key, uid=0):
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
try:
plain = crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError:
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
return {"set": bool(plain), "chars": len(plain),
"tail": plain[-4:] if len(plain) >= 4 else "",
"broken": False, "at": (at["updated_at"] if at else "") or ""}
# ---------------- 账号 ----------------
def user_by_id(conn, uid):
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
def user_by_name(conn, username):
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
def active_users(conn):
"""启用状态的账号(调度器按人遍历)。"""
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
def session_ver_of(row):
"""安全读取 session_ver。
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
那会把「一次可恢复的登录失效」变成「整站 500」。
"""
try:
if "session_ver" not in row.keys():
return 0
except AttributeError: # 不是 Row(dict 等)
return 0
return int(row["session_ver"] or 0)
def bump_session_ver(conn, uid):
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
旧会话依然有效到 12 小时之后。
"""
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
(int(uid or 0),))
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
# ---------------- Flask 集成 ----------------
def get_db():
from flask import g
if "db" not in g:
g.db = connect()
return g.db
def close_db(exc=None):
from flask import g
db = g.pop("db", None)
if db is not None:
try:
db.close()
except sqlite3.Error:
pass