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 行删除
+1 -1
查看文件
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
from . import config, db, security
__version__ = "1.3.0"
__version__ = "1.4.0"
PROJECT_NAME = config.PROJECT_NAME
+637
查看文件
@@ -0,0 +1,637 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。
为什么需要它
------------
原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个:
* 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧)
* 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉
* 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路
本模块的三条设计决定
--------------------
1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。
它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是
一个「某一时刻的完整库」。手工 cp 做不到这一点。
2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选
`instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复;
而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开
settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore /
.dockerignore 与 docs/DEPLOYMENT.md)。
3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移
到当前 schema,然后在**一个写事务**里整表搬过去。这样:
* 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件
swap 会因句柄占用直接失败);
* 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完;
* 中途失败就是一个事务回滚,不会留下半个库。
恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。
恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话
立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好
相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。
"""
import hashlib
import json
import logging
import os
import shutil
import sqlite3
import tempfile
import zipfile
from datetime import datetime, timedelta
from . import collect, config, db
log = logging.getLogger("wb.backup")
SUFFIX = ".zip"
MANIFEST = "manifest.json"
INSTANCE_NAME = "instance.json"
FORMAT_VERSION = 1
# 恢复时整表搬运的表清单。
# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」,
# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目
# (而按需重建索引是幂等的,见 sync_index)。
RESTORE_TABLES = ("users", "settings", "usage_records",
"collect_runs", "audit_log", "captchas")
class BackupError(Exception):
"""备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。"""
# ---------------- 路径与文件名 ----------------
def backup_dir():
config.ensure_dirs()
return config.BACKUP_DIR
def safe_name(name):
"""把用户传来的文件名收敛成「备份目录下的一个 zip」。
必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 ——
下载与恢复接口都直接吃文件名,这里是唯一的收口点。
"""
base = os.path.basename(str(name or "").strip().replace("\\", "/"))
if not base or base in (".", "..") or not base.endswith(SUFFIX):
raise BackupError("备份文件名不合法")
if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-"
for c in base):
raise BackupError("备份文件名含非法字符")
return base
def path_of(name):
return os.path.join(backup_dir(), safe_name(name))
def human(n):
n = float(n or 0)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit))
n /= 1024.0
def _app_version():
from . import __version__ # 延迟导入,避开包初始化顺序
return __version__
# ---------------- 元数据 ----------------
def _db_files():
"""数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。"""
out = []
d = config.DATA_DIR
if not os.path.isdir(d):
return out
for fn in sorted(os.listdir(d)):
if not fn.endswith(".sqlite"):
continue
p = os.path.join(d, fn)
if os.path.isfile(p):
out.append(p)
return out
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _snapshot(src_path, dst_path):
"""用在线备份 API 生成一致快照(src 有并发写也安全)。"""
src = sqlite3.connect(src_path, timeout=8.0)
try:
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
def _stats():
"""归档时的关键计数,用于「挑一份恢复」与事后核对。"""
p = config.SQLITE_PATH
if not os.path.exists(p):
return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True)
try:
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv}
finally:
conn.close()
def _extract_safely(zf, dest, names):
"""只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。
`ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外,
而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。
"""
for n in names:
base = os.path.basename(n.replace("\\", "/"))
if not base or base in (".", ".."):
continue
with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst:
shutil.copyfileobj(fsrc, fdst)
# ---------------- 生成备份 ----------------
def create(conn, trigger="manual", actor=None, note=""):
"""打一份新备份,返回结果 dict。失败抛 BackupError。"""
dbs = _db_files()
if not dbs:
raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = "usage-%s%s" % (stamp, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq = 1
while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖
name = "usage-%s-%d%s" % (stamp, seq, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq += 1
st = _stats()
tmpdir = tempfile.mkdtemp(prefix="wb-backup-")
files_meta = []
try:
for i, src in enumerate(dbs):
fn = os.path.basename(src)
# 主库用在线备份 API;其余库(当前没有,但口径要留全)同理
snap = os.path.join(tmpdir, fn)
_snapshot(src, snap)
files_meta.append({"name": fn, "bytes": os.path.getsize(snap),
"sha256": _sha256(snap), "primary": src == config.SQLITE_PATH})
inst = config.INSTANCE_FILE
if os.path.exists(inst):
shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME))
files_meta.append({"name": INSTANCE_NAME,
"bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)),
"sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)),
"secret": True})
manifest = {
"format": FORMAT_VERSION,
"app": config.PROJECT_NAME,
"version": _app_version(),
"created_at": db.now_str(),
"trigger": trigger,
"actor": actor or "",
"note": note or "",
"stats": st,
"files": files_meta,
"restore_note": ("恢复会整表替换 usage_records / settings / users 等;"
"恢复前系统会自动先备份当前库。"),
}
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
for fn in [f["name"] for f in files_meta]:
z.write(os.path.join(tmpdir, fn), fn)
except Exception:
try:
os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」
except OSError:
pass
raise
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
size = os.path.getsize(dest)
digest = _sha256(dest)
db.audit(conn, "backup_create", actor or "system",
"生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]),
"127.0.0.1", 0)
return {"ok": True, "filename": name, "bytes": size, "sha256": digest,
"trigger": trigger, "stats": st,
"message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])}
# ---------------- 索引(表 <- 磁盘) ----------------
def _row_exists(conn, filename):
return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None
def sync_index(conn):
"""把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。
手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存,
**磁盘才是事实来源**,所以这里做双向对齐而不是只信表。
"""
files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)]
for fn in files:
if _row_exists(conn, fn):
continue
p = os.path.join(backup_dir(), fn)
meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
created = ""
# 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。
# 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是
# 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。
trig = "external"
actor = None
note = "从磁盘发现"
try:
info = read_manifest(p)
meta = dict(meta, **info.get("stats", {}))
created = info.get("created_at") or ""
trig = (info.get("trigger") or "").strip() or "external"
actor = (info.get("actor") or "").strip() or None
note = (info.get("note") or "").strip() or note
except BackupError:
pass
if not created:
# 读不出 manifest 就用文件时间,至少让排序有意义
try:
created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S")
except OSError:
created = db.now_str()
conn.execute(
"INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor,"
" schema_ver,records,credits,users,note,missing)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,0)",
(fn, os.path.getsize(p), "", created, trig, actor,
meta.get("schema_ver", 0), meta.get("records", 0),
meta.get("credits", 0.0), meta.get("users", 0), note))
# 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责
for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall():
gone = not os.path.exists(os.path.join(backup_dir(), r["filename"]))
want = 1 if gone else 0
if r["missing"] != want:
conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"]))
return len(files)
def listing(conn):
"""备份清单(新→旧),附磁盘实际大小。"""
out = []
for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"):
p = os.path.join(backup_dir(), r["filename"])
exists = os.path.exists(p)
d = dict(r)
d["exists"] = exists
if exists:
d["bytes"] = os.path.getsize(p)
d["size_h"] = human(d["bytes"])
out.append(d)
return out
def total_bytes(conn):
return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0]
# ---------------- 校验 ----------------
def _name_map(zf):
"""归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。"""
m = {}
for n in zf.namelist():
b = os.path.basename(n.replace("\\", "/"))
if b:
m[b] = n
return m
def read_manifest(path):
try:
with zipfile.ZipFile(path) as z:
entry = _name_map(z).get(MANIFEST)
if entry is None:
raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST)
return json.loads(z.read(entry).decode("utf-8"))
except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e:
raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e))
def verify(path):
"""校验一份归档是否可用于恢复(不修改任何东西)。"""
if not os.path.exists(path):
raise BackupError("备份文件不存在或已被删除")
man = read_manifest(path)
if int(man.get("format") or 0) > FORMAT_VERSION:
raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序"
% (man.get("format"), FORMAT_VERSION))
files = man.get("files") or []
primary = [f for f in files if f.get("primary")] or (files[:1] if files else [])
if not primary:
raise BackupError("归档里没有数据库文件")
tmpdir = tempfile.mkdtemp(prefix="wb-verify-")
try:
with zipfile.ZipFile(path) as z:
nm = _name_map(z)
for f in files:
fn = os.path.basename(str(f.get("name") or ""))
if not fn:
raise BackupError("归档条目名不合法:%s" % f.get("name"))
if fn not in nm:
raise BackupError("归档缺少文件:%s" % f.get("name"))
_extract_safely(z, tmpdir, [nm[fn]])
db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(db_path):
raise BackupError("归档里的主数据库文件解不出来")
conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True)
try:
integ = conn.execute("PRAGMA integrity_check").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
finally:
conn.close()
except sqlite3.Error as e:
raise BackupError("归档里的数据库打不开:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if integ != "ok":
raise BackupError("归档里的数据库完整性检查未通过:%s" % integ)
if uv > db.DB_SCHEMA_VERSION:
raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复"
% (uv, db.DB_SCHEMA_VERSION))
return {"manifest": man, "integrity": integ, "schema_ver": uv,
"records": n, "credits": round(cr or 0, 2), "users": users,
"created_at": man.get("created_at") or "",
"version": man.get("version") or ""}
# ---------------- 恢复 ----------------
def _table_cols(conn, db_name, table):
return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))]
def _copy_tables(conn, src_path):
"""把 src 库里的业务表整表搬到主库,一个事务内完成。
用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库
建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默
错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。
"""
conn.execute("ATTACH DATABASE ? AS src", (src_path,))
try:
plan = []
for t in RESTORE_TABLES:
dst_cols = _table_cols(conn, "main", t)
src_cols = _table_cols(conn, "src", t)
if not src_cols:
continue # 归档里没有这张表(更老的版本)
common = [c for c in dst_cols if c in src_cols]
if not common:
continue
plan.append((t, common))
conn.execute("BEGIN IMMEDIATE")
try:
for t, cols in plan:
cl = ",".join('"%s"' % c for c in cols)
conn.execute("DELETE FROM main.%s" % t)
conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s"
% (t, cl, cl, t))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
return [t for t, _ in plan]
finally:
conn.execute("DETACH DATABASE src")
def _restore_instance(src_file):
"""把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。
必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失,
而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。
"""
dst = config.INSTANCE_FILE
if os.path.exists(dst):
bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S"))
try:
shutil.copy2(dst, bak)
except OSError as e:
raise BackupError("备份 instance.json 失败,已中止:%s" % e)
try:
shutil.copy2(src_file, dst)
if os.name == "posix":
os.chmod(dst, 0o600)
except OSError as e:
raise BackupError("写入 instance.json 失败:%s" % e)
return True
def restore(conn, filename, include_instance=True, actor=None):
"""从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。
副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效**
(恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。
返回结果 dict;失败抛 BackupError。
"""
path = path_of(filename)
info = verify(path) # 先验,验不过就不动任何东西
# 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。
try:
safety = create(conn, trigger="pre-restore", actor=actor or "system",
note="恢复 %s 之前的自动快照" % filename)
except Exception as e: # noqa: BLE001
raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e)
tmpdir = tempfile.mkdtemp(prefix="wb-restore-")
try:
with zipfile.ZipFile(path) as z:
_extract_safely(z, tmpdir, z.namelist())
primary = [f for f in info["manifest"]["files"] if f.get("primary")]
if not primary:
primary = [info["manifest"]["files"][0]]
src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(src_path):
raise BackupError("归档里缺少主数据库文件")
# 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复,
# 而且迁移失败时正本一个字节都没动。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.row_factory = sqlite3.Row
db.init_db(conn=tmp_conn, create_admin=False)
finally:
tmp_conn.close()
# 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。
# 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
tmp_conn.execute("PRAGMA journal_mode=DELETE")
finally:
tmp_conn.close()
# 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。
with collect._Lock():
moved = _copy_tables(conn, src_path)
# 数据整表换过了 —— 让**所有**会话立即失效。
#
# 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在
# 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值,
# 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经
# 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1")
inst = False
if include_instance:
inst_file = os.path.join(tmpdir, INSTANCE_NAME)
if os.path.exists(inst_file):
inst = _restore_instance(inst_file)
out = _stats()
out["moved"] = moved
out["instance"] = inst
except collect.Busy as e:
raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e)
except sqlite3.Error as e:
raise BackupError("恢复过程中数据库报错,已回滚:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
db.audit(conn, "backup_restore", actor or "system",
"从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)"
% (filename, out["records"], out["credits"], out["users"],
",含 instance.json" if out["instance"] else "",
safety.get("filename")),
"127.0.0.1", 0)
return {
"ok": True, "filename": filename, "safety_backup": safety.get("filename"),
"moved": out["moved"], "restored_instance": out["instance"],
"stats": out, "message":
"已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。"
"恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。"
% (filename, out["records"], out["credits"], out["users"],
safety.get("filename")),
}
# ---------------- 删除 / 清理 ----------------
def delete(conn, filename, actor=None):
"""删除一份备份(文件 + 索引行)。"""
name = safe_name(filename)
p = path_of(name)
if not os.path.exists(p):
conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,))
raise BackupError("备份文件不存在(可能已被手工删除)")
os.remove(p)
conn.execute("DELETE FROM backups WHERE filename=?", (name,))
db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0)
return {"ok": True, "filename": name, "message": "已删除备份 " + name}
def prune(conn, keep=None, actor=None):
"""按「保留份数」清理最旧的备份。返回删除清单。"""
if keep is None:
keep = db.get_int(conn, "backup_keep", 7)
keep = max(1, min(100, int(keep or 1)))
rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall()
# 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额
alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))]
victims = alive[keep:]
removed = []
for r in victims:
try:
os.remove(os.path.join(backup_dir(), r["filename"]))
conn.execute("DELETE FROM backups WHERE id=?", (r["id"],))
removed.append(r["filename"])
except OSError as e:
log.warning("清理旧备份 %s 失败:%s", r["filename"], e)
if removed:
db.audit(conn, "backup_prune", actor or "system",
"按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)),
"127.0.0.1", 0)
return removed
# ---------------- 自动备份 ----------------
def last_auto_at(conn):
r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')"
).fetchone()
return (r[0] if r and r[0] else "")
def due(conn, now=None):
"""自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。"""
if not db.get_bool(conn, "backup_enabled", True):
return False, 0.0, 0
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return True, -1.0, every
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return True, -1.0, every
hrs = (now - dt).total_seconds() / 3600.0
return hrs >= every, hrs, every
def maybe_auto(conn, now=None):
"""调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。"""
ok, hrs, every = due(conn, now)
if not ok:
return None
try:
r = create(conn, trigger="auto", actor="system",
note="自动备份(周期 %d 小时)" % every)
except Exception as e: # noqa: BLE001
log.error("自动备份失败:%s", e)
return None
removed = prune(conn, actor="system")
if removed:
log.info("自动备份后清理旧备份 %d 份", len(removed))
log.info("自动备份完成:%s", r["message"])
return r
def next_auto_at(conn, now=None):
"""下一次自动备份时间(给界面显示)。"""
if not db.get_bool(conn, "backup_enabled", True):
return None
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return now
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return now
nxt = dt + timedelta(hours=every)
return nxt if nxt > now else now
+113 -32
查看文件
@@ -18,11 +18,20 @@
字模
----
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移,
笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。
强度说明(为什么要这么多花样)
------------------------------
字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。
在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上
尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。
纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。
反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 ——
验证码是「提高成本」而不是「杜绝」。
"""
import hmac
import os
import math
import random
import secrets
import struct
@@ -140,54 +149,126 @@ class _Canvas:
return bytes(self.buf)
def _brush_line(cv, x0, y0, x1, y1, color, r):
"""用 r×r 方刷画一条线。
旋转后的笔画如果只用点阵格逐个平移,会出现锯齿状断点 —— 一圈一圈的
缝隙正好给「连通域分析」留了把手。这里改成沿线段走样并盖方刷,
笔画连续,旋转也不散架。
"""
steps = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
o = r // 2
for i in range(steps + 1):
t = i / float(steps)
x = int(round(x0 + (x1 - x0) * t))
y = int(round(y0 + (y1 - y0) * t))
cv.rect(x - o, y - o, r, r, color)
def _draw_char(cv, glyph, cx, cy, scale, color, rng):
"""在 (cx, cy) 为中心画一个字符:随机旋转 + 切变 + 波浪 + 粗笔画。
三段变换按「点阵坐标 -> 缩放居中 -> 切变 -> 旋转 -> 波浪纵向偏移」依次施加。
顺序不能乱:先切变再旋转,得到的才是「斜着写的手写体」而不是「被斜切的旋转体」。
"""
ang = rng.uniform(-0.38, 0.38) # 弧度,约 ±22°
cos_a, sin_a = math.cos(ang), math.sin(ang)
shear = rng.uniform(-0.32, 0.32)
amp = rng.uniform(0.0, 2.6) # 波浪振幅(像素)
period = rng.uniform(18.0, 42.0)
phase = rng.uniform(0.0, 6.283)
half_w = GLYPH_W * scale / 2.0
half_h = GLYPH_H * scale / 2.0
r = max(2, scale)
def place(col, row):
px = (col + 0.5) * scale - half_w
py = (row + 0.5) * scale - half_h
px += shear * py
x = cx + px * cos_a - py * sin_a
y = cy + px * sin_a + py * cos_a
return x, y + amp * math.sin(x / period + phase)
for row, bits in enumerate(glyph):
col = 0
while col < len(bits):
if bits[col] != "#":
col += 1
continue
start = col
while col + 1 < len(bits) and bits[col + 1] == "#":
col += 1 # 连续的一段合起来画,笔画才连得上
x0, y0 = place(start, row)
x1, y1 = place(col, row)
_brush_line(cv, x0, y0, x1, y1, color, r)
col += 1
def render(code, width=150, height=56, scale=5, rng=None):
"""把验证码渲染成 PNG 字节串。
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 ——
目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同,
从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。
"""
rng = rng or random.SystemRandom()
n = len(code)
gap = 7
text_w = n * GLYPH_W * scale + (n - 1) * gap
if text_w + 16 > width:
width = text_w + 16
x0 = max(4, (width - text_w) // 2)
y0 = max(3, (height - GLYPH_H * scale) // 2)
gap = 9
# 宽度按最大可能字号算,且左右各留够旋转半径 ——
# 旋转后的字符会往两侧探出约半个字高,留窄了最外侧那个字会被裁掉一截,
# 而「被裁掉一角的字符」会直接变成一次没道理的输错(体验问题,不是安全问题)。
text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap
need_w = text_w + int(GLYPH_H * (scale + 1) * 0.9) + 8
if need_w > width:
width = need_w
# 高度同理:旋转后的字符比原始点阵高不少,切了顶就等于少一个笔画特征
need_h = int(GLYPH_H * (scale + 1) * 1.7) + 8
if need_h > height:
height = need_h
x0 = max(5, (width - text_w) // 2)
y0 = height // 2
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
# 而且打印/截图后对比度更差。
bg = tuple(rng.randint(238, 252) for _ in range(3))
cv = _Canvas(width, height, bg)
# 背景不做纯色:纯色底可以用一个阈值把前景整片切出来。
# 用「两色之间做斜向渐变」能让全局二值化的效果明显变差。
c1 = tuple(rng.randint(236, 252) for _ in range(3))
c2 = tuple(rng.randint(214, 240) for _ in range(3))
slant = rng.uniform(-1.0, 1.0)
cv = _Canvas(width, height, c1)
for y in range(height):
for x in range(width):
t = (x / float(width - 1 or 1)) * 0.6 + (y / float(height - 1 or 1)) * 0.4
t = min(1.0, max(0.0, t + slant * 0.15))
cv.dot(x, y, tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)))
# 1) 干扰线(先画,压在字下面,不遮挡主体)
for _ in range(4):
# 1) 底层干扰线(先画,压在字下面)
for _ in range(3):
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(150, 205) for _ in range(3)))
tuple(rng.randint(170, 215) for _ in range(3)))
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
# 2) 字符本体
step = GLYPH_W * (scale + 1) + gap
for i, ch in enumerate(code):
glyph = _FONT.get(ch)
if glyph is None:
continue
color = tuple(rng.randint(20, 105) for _ in range(3))
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
gy = y0 + rng.randint(-2, 2)
for row, bits in enumerate(glyph):
for col, bit in enumerate(bits):
if bit == "#":
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
# 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了
s = max(3, scale + rng.choice((-1, 0, 0, 1)))
cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0)
cy = y0 + rng.uniform(-3.0, 3.0)
color = tuple(rng.randint(15, 95) for _ in range(3))
_draw_char(cv, glyph, cx, cy, s, color, rng)
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
for _ in range(46):
# 3) 前景噪点:破坏「按连通域找字符」的假设
for _ in range(70):
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(90, 190) for _ in range(3)))
tuple(rng.randint(90, 195) for _ in range(3)))
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
for _ in range(3):
# 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出,
# 所以刻意控制成 2~3 条细线。
for _ in range(rng.randint(2, 3)):
y = rng.randint(2, height - 3)
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
cv.line(0, y, width - 1, y + rng.randint(-11, 11),
tuple(rng.randint(120, 175) for _ in range(3)))
return encode_png(width, height, cv.bytes())
+28
查看文件
@@ -205,6 +205,22 @@ def record_count(conn, uid):
(uid or 0,)).fetchone()[0]
def max_range_days(conn):
"""单次采集允许的最长跨度(天)。
配置值是给管理员的旋钮,**代码层的硬顶**才是兜底:历史脏数据、直接改库、
或者某次误配置都不该让一次请求变成几千次云端调用。
接口校验、页面提示、sync() 里的实际收窄都读这一个函数,避免三处口径漂移。
"""
n = db.get_int(conn, "collect_max_range_days", config.COLLECT_MAX_RANGE_DAYS_HARD)
return max(1, min(config.COLLECT_MAX_RANGE_DAYS_HARD, n))
def min_interval_seconds(conn):
"""同一账号两次手动采集之间的最小间隔(秒)。"""
return max(0, min(3600, db.get_int(conn, "collect_min_interval_seconds", 60)))
def last_ts(conn, uid):
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0]
@@ -285,6 +301,18 @@ def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None
end = to_dt or now
if start >= end:
start = end - timedelta(minutes=rewind)
# 跨度上限(**代码层兜底**,不只是接口校验)。
# 采集一次 = 对云端发 ceil(条数/page_size) 次请求,跨度越长请求越多。
# 不设顶时,一个注册账号用 from=2000-01-01 就能让服务端替它打几千次云端,
# 同时独占全局采集锁与一个 waitress 线程 —— 最省力的资源耗尽方式。
# 这里对「显式跨度」和「断点很旧导致的实际跨度」一视同仁地收窄。
max_days = max_range_days(conn)
if end - start > timedelta(days=max_days):
original = start
start = end - timedelta(days=max_days)
_log("[warn] 请求跨度超过上限 %d 天,已自动收窄起点:%s -> %s"
% (max_days, original.strftime("%Y-%m-%d %H:%M:%S"),
start.strftime("%Y-%m-%d %H:%M:%S")))
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
end.strftime("%Y-%m-%d %H:%M:%S")))
+118 -10
查看文件
@@ -28,6 +28,13 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
APP_LOG = os.path.join(LOG_DIR, "app.log")
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
# 备份落点。**刻意与 DATA_DIR 分开**:
# * 容器里 DATA_DIR 挂的是数据卷,`docker compose down -v` 会连卷一起删;
# 备份若躺在同一个卷里,就等于「正本与副本同时消失」—— 备份的意义没了。
# * 备份里含 settings 的凭证密文与 users 的口令散列,必须能单独控制权限、
# 单独挂卷、单独排除出镜像(见 .dockerignore 的 backups/)。
BACKUP_DIR = os.environ.get("WB_BACKUP_DIR") or os.path.join(BASE_DIR, "backups")
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
# 后面两个保留以兼容「还没挪走」的部署。
@@ -58,6 +65,13 @@ DEFAULTS = {
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
"max_schedule_slots_per_day": "6", # 每天最多几个时刻(挡住「填 200 个时刻」)
"collect_min_interval_seconds": "60", # 同一账号两次手动采集的最小间隔
"collect_max_range_days": "31", # 单次采集的最长跨度(硬顶 31 天 = 1 个月)
# ---- 实例级:自动备份 ----
"backup_enabled": "1", # 是否开启自动备份
"backup_interval_hours": "24", # 备份周期(小时)
"backup_keep": "7", # 保留最近几份,超出的自动删除最旧的
# ---- 实例级:采集参数 ----
"page_size": "200",
"rewind_minutes": "2", # 断点回退分钟数
@@ -91,6 +105,12 @@ GLOBAL_KEYS = {
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
"max_schedule_slots_per_day",
# 任务频率与采集跨度(v1.4.0 起:对外提供服务时必须能限流,
# 否则一个注册账号就能拿 /api/collect 把云端与线程池打满)
"collect_min_interval_seconds", "collect_max_range_days",
# 自动备份(谁掌握备份谁就掌握全库数据,所以归管理员)
"backup_enabled", "backup_interval_hours", "backup_keep",
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
"verify_days", "timeout", "ssl_verify",
@@ -155,18 +175,33 @@ NUM_SETTINGS = {
"catch_up_grace_hours": (1, 168, "小时"),
"captcha_length": (4, 6, "个字符"),
"register_max_per_ip": (1, 50, "个/天"),
"max_schedule_slots_per_day": (1, 12, "个/天"),
"collect_min_interval_seconds": (0, 3600, "秒"),
"collect_max_range_days": (1, 31, "天"),
"backup_interval_hours": (1, 720, "小时"),
"backup_keep": (1, 100, "份"),
}
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"}
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register", "backup_enabled"}
# 采集跨度的**硬顶**:无论 settings 里被改成什么(含历史脏数据、手工改库),
# 代码层一律按这个上限夹一次。写进配置只是给管理员一个更严的旋钮,
# 不是「改大就能突破」——上限必须由代码兜底,不能只靠校验。
COLLECT_MAX_RANGE_DAYS_HARD = 31
# 每日调度时刻的硬顶(同上)
SCHEDULE_SLOTS_HARD_MAX = 12
_TRUE = ("1", "true", "yes", "on", "是", "启用")
def normalize_setting(key, raw):
def normalize_setting(key, raw, conn=None):
"""校验并规范化单个设置值。
返回 (value, error):
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
* `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受
`max_schedule_slots_per_day` 约束),有连接时才查得到。
不传时只做代码层的硬顶校验,所以离线调用不会因此失败。
"""
if key not in DEFAULTS:
return None, "未知配置项:%s" % key
@@ -196,6 +231,19 @@ def normalize_setting(key, raw):
parsed = scheduler.parse_times(raw)
if not parsed:
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
if len(parsed) > SCHEDULE_SLOTS_HARD_MAX:
return None, "每日时刻最多 %d 个(当前填了 %d 个)" % (
SCHEDULE_SLOTS_HARD_MAX, len(parsed))
# 可配置的更严上限:时刻数量直接决定调度器的采集频次,
# 是「一个账号能不能把云端与线程池打满」的开关,所以要有刹车。
if conn is not None:
from . import db as _db
cap = _db.get_int(conn, "max_schedule_slots_per_day", 6)
cap = max(1, min(SCHEDULE_SLOTS_HARD_MAX, cap))
if len(parsed) > cap:
return None, ("每日时刻最多 %d 个(当前填了 %d 个)。"
"如需更多,请先把「每日调度时刻上限」调大。"
% (cap, len(parsed)))
return ",".join(parsed), None
if key == "captcha_policy":
@@ -208,8 +256,14 @@ def normalize_setting(key, raw):
v = str(raw).strip()
if not v:
return None, "%s 不能为空" % key
if key == "api_base" and not v.startswith(("http://", "https://")):
return None, "接口基址需以 http:// 或 https:// 开头"
if key == "api_base":
if not v.startswith(("http://", "https://")):
return None, "接口基址需以 http:// 或 https:// 开头"
# 云元数据地址永远不该是「云端接口」:它是 SSRF 拿云上临时凭证
# 最经典的一跳,而且没有任何合法的采集场景需要它。
host = v.split("//", 1)[1].split("/", 1)[0].split(":")[0].lower()
if host in ("169.254.169.254", "metadata.google.internal", "[fd00:ec2::254]"):
return None, "接口基址不能指向云元数据地址"
return v, None
if key == "cookie":
@@ -222,8 +276,57 @@ def normalize_setting(key, raw):
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
DEFAULT_PORT = 8848
SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数
LOGIN_LOCK_MINUTES = 10
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁)
LOGIN_LOCK_MINUTES = 10 # 硬锁时长(仅 IP 维度)
# 用户名维度的**软退避**:对外提供服务后,「知道一个用户名就能把它锁死 10 分钟」
# 本身就是一种攻击(拿管理员用户名当武器,别人也用不了)。所以用户名维度
# 只产生秒级、递增、有封顶的等待,真正的重锁只按来源 IP 施加。
USER_SOFT_THRESHOLD = 5 # 同一用户名失败超过这个次数才开始退避
USER_SOFT_CAP_SECONDS = 60 # 退避封顶
# 同一来源的登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」的形态
LOGIN_ATTEMPTS_PER_IP = 40
LOGIN_ATTEMPTS_WINDOW = 300 # 秒
# ---------------- 反向代理与传输安全 ----------------
def _env_flag(name, default="0"):
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
# 是否信任 X-Forwarded-For。**默认不信任**。
# 直接暴露给公网(或前面只有一个「追加型」代理)时,XFF 的第 0 段是攻击者
# 自己填的:一旦采信,验证码限速、注册配额、登录锁定三道 IP 防线会同时失效
# (实测:每次换一个伪造 XFF,45 次验证码请求全部放行)。
# 只有在**你自己的**反向代理会重写该头(nginx: `$remote_addr`)时才置 1。
TRUST_PROXY = _env_flag("WB_TRUST_PROXY")
# 强制跳转 HTTPS(配合反代时用;读到 X-Forwarded-Proto: https 就不跳)
FORCE_HTTPS = _env_flag("WB_FORCE_HTTPS")
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
COOKIE_SECURE = _env_flag("WB_COOKIE_SECURE")
# 访问日志:waitress 自己不记 access log,上线后没有访问日志等于出事无据可查。
# 只记非静态资源请求,写进 logs/app.log(滚动 2MB × 3)。
ACCESS_LOG = _env_flag("WB_ACCESS_LOG", "1")
# waitress 线程数。与容器 CPU 上限配套:线程越多,单实例能同时吃进的
# 慢请求(如采集、导出)就越多 —— 对外提供服务时这是资源上限的一部分。
THREADS = int(os.environ.get("WB_THREADS") or 8)
# 首个管理员的初始口令。**不给默认值**:留空时 db.init_db 会生成一个随机口令
# 并只在启动日志里打印一次 —— 硬编码一个 admin123 等于把公网实例的钥匙挂在门上。
ADMIN_USER = os.environ.get("WB_ADMIN_USER") or "admin"
ADMIN_PASSWORD = os.environ.get("WB_ADMIN_PASSWORD") or ""
# 口令黑名单:这些是自动撞库字典的头几页,命中即拒。
# 只在「设置/修改口令」时校验(登录不校验),所以不会把用老口令的人挡在门外。
WEAK_PASSWORDS = {
"12345678", "123456789", "1234567890", "password", "password1", "password123",
"passw0rd", "qwertyui", "qwerty123", "abc12345", "abcd1234", "admin123",
"admin888", "admin1234", "administrator", "root1234", "letmein1", "welcome1",
"iloveyou", "monkey123", "dragon123", "sunshine", "princess", "football",
"baseball", "11111111", "00000000", "88888888", "66666666", "asdasd123",
"1qaz2wsx", "zxcvbnm1", "a1234567", "workbuddy", "codebuddy",
}
# ---------------- 账号与口令策略 ----------------
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
@@ -233,13 +336,13 @@ PASSWORD_MAX = 128
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
PROFILE_EMAIL_MAX = 128
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on")
# 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段,
# 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定,
# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
def ensure_dirs():
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR):
os.makedirs(d, exist_ok=True)
@@ -265,6 +368,11 @@ def _instance_init(key, maker):
try:
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 这个文件里躺着 SECRET_KEY 与 cookie_key,权限等同管理员口令。
# 默认 umask 022 会留下 0644(同机其他用户可读),所以在 POSIX 上
# 显式收紧到 0600。Windows 没有这个概念,忽略即可。
if os.name == "posix":
os.chmod(INSTANCE_FILE, 0o600)
except OSError:
pass # 只读文件系统时退化为「本次进程内有效」
return val
+76 -8
查看文件
@@ -26,13 +26,17 @@
* `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
@@ -41,11 +45,22 @@ _initialized = False
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
DB_SCHEMA_VERSION = 3
# 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 被换过。"""
@@ -167,7 +182,8 @@ def _migrate(conn):
for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"),
("last_login_ip", "TEXT")):
("last_login_ip", "TEXT"),
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col)
@@ -264,6 +280,38 @@ def _promote_personal_to_global(conn):
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
@@ -316,12 +364,7 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
from .security import hash_password
pwd = admin_password or "admin123"
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
_create_first_admin(conn, admin_user, admin_password, ts)
_initialized = True
return migrated
finally:
@@ -458,6 +501,31 @@ def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
def session_ver_of(row):
"""安全读取 session_ver。
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
那会把「一次可恢复的登录失效」变成「整站 500」。
"""
try:
if "session_ver" not in row.keys():
return 0
except AttributeError: # 不是 Row(dict 等)
return 0
return int(row["session_ver"] or 0)
def bump_session_ver(conn, uid):
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
旧会话依然有效到 12 小时之后。
"""
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
(int(uid or 0),))
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
+11 -1
查看文件
@@ -26,7 +26,7 @@ import os
import threading
from datetime import datetime, timedelta
from . import collect, db
from . import backup, collect, db
log = logging.getLogger("wb.scheduler")
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
@@ -176,6 +176,16 @@ class Scheduler:
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
except Exception as e:
log.error("账号 %s 采集失败:%s", u["username"], e)
# ---- 自动备份(实例级,与具体账号无关,所以放在账号循环之外)----
# 有采集在跑就跳过,等下一轮:备份会整库读一遍,没必要和采集抢磁盘。
try:
if os.path.exists(collect.LOCK_PATH):
log.debug("有采集在跑,本次跳过自动备份")
else:
backup.maybe_auto(conn, now)
except Exception as e: # 备份失败不能拖累调度本身
log.exception("自动备份出错:%s", e)
return True
+23
查看文件
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效
created_at TEXT,
register_ip TEXT, -- 自助注册来源,用于每日限额
last_login_at TEXT,
@@ -119,3 +120,25 @@ CREATE TABLE IF NOT EXISTS captchas (
);
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
-- ---------------- 备份索引 ----------------
-- 表里只放「元数据」,备份文件本身在 config.BACKUP_DIR(**不在** data/ 卷里,
-- 免得 `docker compose down -v` 把正本和备份一起删掉)。
-- 刻意不记录备份内容、也不记录任何凭证 —— 这里只是一份可下载清单。
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE, -- 备份目录内的文件名(不含路径)
bytes INTEGER NOT NULL DEFAULT 0, -- 归档大小
sha256 TEXT NOT NULL DEFAULT '', -- 归档整体校验值(前 64 位十六进制)
created_at TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'manual', -- manual | auto | cli | pre-restore
actor TEXT, -- 触发者用户名(自动备份为 system)
schema_ver INTEGER NOT NULL DEFAULT 0, -- 归档时的 user_version
records INTEGER NOT NULL DEFAULT 0, -- 归档时的记录条数(便于挑一份恢复)
credits REAL NOT NULL DEFAULT 0,
users INTEGER NOT NULL DEFAULT 0,
note TEXT, -- 备注 / 恢复来源
missing INTEGER NOT NULL DEFAULT 0 -- 1 = 文件已不在磁盘上(手工删过)
);
CREATE INDEX IF NOT EXISTS idx_backups_at ON backups(created_at DESC);
+216 -9
查看文件
@@ -10,14 +10,26 @@
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
users.status 与 users.session_ver,不靠会话过期来兜底。
3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。
来源 IP 的取法(对外提供服务时最容易出错的一处)
------------------------------------------------
`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`:
反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是
攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道
IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头)
时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的
一跳,由我们自己的代理写入,客户端伪造不了。
"""
import functools
import hmac
import ipaddress
import logging
import re
import secrets
import sqlite3
import time
from flask import (current_app, flash, g, jsonify, redirect, render_template,
@@ -25,10 +37,50 @@ from flask import (current_app, flash, g, jsonify, redirect, render_template,
from . import captcha, config, db
log = logging.getLogger("wb.security")
# ---------------- 来源 IP(全站唯一入口) ----------------
def _valid_ip(s):
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def client_ip():
"""当前请求的客户端地址。
* `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。
直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。
* 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。
最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。
多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠
代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。
"""
remote = (request.remote_addr or "").strip()
if not config.TRUST_PROXY:
return remote
raw = request.headers.get("X-Forwarded-For", "")
if not raw:
return remote
for part in reversed([p.strip() for p in raw.split(",")]):
# 去掉 IPv6 的 [..]:port 写法
cand = part.strip("[]").split("%")[0]
if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit():
cand = cand.rsplit(":", 1)[0] # IPv4:port
if _valid_ip(cand):
return cand
# 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用
log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120])
return remote
# ---------------- 失败计数(内存即可) ----------------
# 单进程部署(见 README 的部署约束),重启清零可接受;
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
_fails = {} # key -> [count, last_ts]
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
@@ -88,9 +140,76 @@ def fail_count(key):
return c[0] if c else 0
# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)----
def ip_lock_left(ip):
c = _fails.get(_ip_key(ip))
if not c or c[0] < config.MAX_LOGIN_FAILS:
return 0
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
# ---- 用户名维度:只做秒级退避,**不做长锁** ----
def user_soft_left(username):
"""知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。
对外提供服务后,管理员用户名是公开信息(导航里就写着),
如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员
十分钟进不去。所以这里改成「递增且有封顶」的秒级等待:
超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。
真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、
也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数,
所以这种「软」不会让爆破变得可行。
"""
c = _fails.get(_user_key(username))
if not c or c[0] < config.USER_SOFT_THRESHOLD:
return 0
delay = min(config.USER_SOFT_CAP_SECONDS,
1 << min(10, c[0] - config.USER_SOFT_THRESHOLD))
return max(0, int(delay - (time.time() - c[1])))
# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」----
def note_try(ip):
now = time.time()
if len(_tries) > _FAILS_MAX_KEYS:
_tries.clear()
cur = _tries.get(ip)
if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW:
_tries[ip] = [1, now]
return 1
cur[0] += 1
return cur[0]
def try_window_left(ip):
cur = _tries.get(ip)
if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP:
return 0
return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1])))
def auth_locked(ip, username=""):
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
"""还需等待的秒数(0 = 放行)。"""
return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username))
def auth_block_reason(ip, username=""):
"""被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。"""
if ip_lock_left(ip):
return "ip"
if try_window_left(ip):
return "rate"
if user_soft_left(username):
return "user"
return ""
def auth_block_message(reason, seconds):
if reason == "ip":
return "该来源登录失败次数过多,请 %d 秒后再试" % seconds
if reason == "rate":
return "登录请求过于频繁,请 %d 秒后再试" % seconds
return "尝试过于频繁,请 %d 秒后再试" % seconds
def note_auth_fail(ip, username=""):
@@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""):
def clear_auth_fail(ip, username=""):
"""登录成功后清掉两个维度的计数。
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
之后,主人仍会被自己之前那几次的退避拖住。
"""
clear_fail(_ip_key(ip))
if username:
clear_fail(_user_key(username))
# ---------------- 通用动作限速(重操作保护) ----------------
# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求:
# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程;
# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车,
# 否则**一个注册账号**就能把实例的线程与带宽吃干净。
_actions = {} # "name:uid" -> next_allowed_ts
def action_allowed(key, min_interval):
"""返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。"""
now = time.time()
if len(_actions) > _FAILS_MAX_KEYS:
_actions.clear()
nxt = _actions.get(key) or 0
if now < nxt:
return False, int(nxt - now) + 1
_actions[key] = now + max(0, int(min_interval))
return True, 0
def action_wait_left(key):
return max(0, int((_actions.get(key) or 0) - time.time()))
# ---------------- 口令 / 用户名策略 ----------------
_USERNAME_RE = re.compile(config.USERNAME_RE)
@@ -149,6 +297,10 @@ def password_problem(new, new2=None, username=None):
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
if classes < 2:
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
if new.lower() in config.WEAK_PASSWORDS:
return "这个密码在常见弱口令字典里,请换一个"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
if username and new.lower() == str(username).lower():
@@ -182,8 +334,12 @@ def login_ok(conn, username, password):
def current_user():
"""当前登录用户(dict)或 None。
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
每个请求回查一次 users 表,两道校验:
* `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
旧会话照样有效到 12 小时之后。
结果缓存在 flask.g 里,一次请求只查一次。
"""
if "wb_user" in g:
return g.wb_user
@@ -192,12 +348,24 @@ def current_user():
if uid:
try:
row = db.get_db().execute(
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
(uid,)).fetchone()
"SELECT id,username,display_name,is_admin,status,session_ver"
" FROM users WHERE id=?", (uid,)).fetchone()
except sqlite3.OperationalError as e:
# users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db
# 就先把 Web 起起来了,老库还没有 session_ver 这一列)。
# 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉,
# 症状会变成「全站所有人被踢下线、日志里什么都没有」——
# 界面上只看到「登录成功又立刻跳回登录页」,极难归因。
log.error("会话校验失败:users 表结构与代码不一致(%s);"
"请执行 `python manage.py init` 完成迁移", e)
row = None
except Exception: # noqa: BLE001 (无请求上下文等)
row = None
if row is None or (row["status"] or "active") != "active":
session.clear()
elif session.get("sv", 0) != db.session_ver_of(row):
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
session.clear()
else:
user = {"id": row["id"], "username": row["username"],
"display_name": row["display_name"] or row["username"],
@@ -219,12 +387,16 @@ def login_session(user):
`session.clear()` 是必须的:既清掉前一次的残留,
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
这一版会话会在下一个请求就被判为过期。
"""
session.clear()
session["uid"] = user["id"]
session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"]
session["adm"] = 1 if user["is_admin"] else 0
session["sv"] = db.session_ver_of(user)
session["login_at"] = db.now_str()
session.permanent = True
@@ -401,19 +573,54 @@ def apply_security_headers(resp):
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
if config.COOKIE_SECURE or config.FORCE_HTTPS:
resp.headers.setdefault("Strict-Transport-Security",
"max-age=31536000; includeSubDomains")
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
resp.headers.setdefault("Cache-Control", "no-store")
return resp
def needs_https_redirect():
"""当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。"""
if not config.FORCE_HTTPS or request.is_secure:
return False
# 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。
# 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。
if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
return False
if request.method not in ("GET", "HEAD"):
return False # 不重定向 POST:会丢请求体,行为难以预期
return True
def _access_log(resp, started):
if not config.ACCESS_LOG:
return resp
path = request.path
if path.startswith("/static/") or path == "/captcha.png":
return resp # 静态资源与验证码出图会把日志刷满
log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code,
int((time.time() - started) * 1000), client_ip())
return resp
def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user
@app.before_request
def _guard():
g.wb_t0 = time.time()
if needs_https_redirect():
url = request.url.replace("http://", "https://", 1)
return redirect(url, code=301)
return check_csrf()
@app.after_request
def _headers(resp):
_access_log(resp, getattr(g, "wb_t0", time.time()))
return apply_security_headers(resp)
+222 -16
查看文件
@@ -23,9 +23,9 @@
import os
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, send_file, session
from .. import collect, config, db, query, scheduler, security
from .. import backup, collect, config, db, query, scheduler, security
from ..security import admin_required, current_user, is_admin, login_required
bp = Blueprint("api", __name__, url_prefix="/api")
@@ -36,6 +36,12 @@ def _uid():
return u["id"] if u else 0
def _ip():
"""客户端地址。一律走 security.client_ip() —— 见那里的注释:
直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。"""
return security.client_ip()
def _arg(name, default=None):
v = request.args.get(name)
return v if v not in (None, "") else default
@@ -193,14 +199,26 @@ def api_status():
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
(uid,)).fetchone()[0]
cred = db.secret_state(conn, "cookie", uid)
nxt_bk = backup.next_auto_at(conn) if u["is_admin"] else None
return jsonify({
"server_time": db.now_str(),
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
# 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会
# 决定要不要渲染管理员专属入口(如「日志管理」「备份管理」)。服务端仍会
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
"is_admin": bool(u["is_admin"]),
"can_edit_schedule": bool(u["is_admin"]),
"can_view_logs": bool(u["is_admin"]),
"can_manage_backups": bool(u["is_admin"]),
"limits": {
# 对外提供服务时的三道闸门,前端据此提前禁用按钮而不是等 409
"collect_min_interval_seconds": collect.min_interval_seconds(conn),
"collect_max_range_days": collect.max_range_days(conn),
"schedule_slots": len(scheduler.slots(conn, uid)),
"schedule_slots_cap":
max(1, min(config.SCHEDULE_SLOTS_HARD_MAX,
db.get_int(conn, "max_schedule_slots_per_day", 6))),
},
"running_lock": os.path.exists(collect.LOCK_PATH),
"scheduler": {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
@@ -209,6 +227,13 @@ def api_status():
"catch_up": db.get_bool(conn, "catch_up", True, uid),
"lockfile": os.path.exists(collect.LOCK_PATH),
},
"backup": {
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last": backup.last_auto_at(conn) or None,
"next": nxt_bk.strftime("%Y-%m-%d %H:%M:%S") if nxt_bk else None,
},
"running_runs": running,
"last_run": dict(last) if last else None,
# 只回「有没有配」与字符数,绝不回凭证内容
@@ -221,10 +246,40 @@ def api_status():
@bp.post("/collect")
@login_required
def api_collect():
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
"""手动触发一次采集(同步执行,页面等待结果)。
对外提供服务后,这里必须有三道闸门,缺一不可:
1. **已有任务在跑就拒绝新任务**。采集是全局单写者(SQLite 同一时刻只允许
一个写进程,见 collect._Lock)。原来的实现是「后来者在锁上等」,
而 waitress 只有 8 个线程 —— 一个人连点几下就能把线程占满,
表现为整个站点变慢甚至无响应。现在明确回 409。
2. **同一账号的最小间隔**。挡住「连点按钮 / 脚本循环调」这种形态;
间隔由 `collect_min_interval_seconds` 控制(实例级,管理员可调)。
3. **跨度上限**。`from=2000-01-01` 会让服务端对云端发出成百上千次请求,
这是最省力的资源耗尽方式。上限 `collect_max_range_days`(硬顶 31 天)。
"""
u = current_user()
conn = db.get_db()
uid = u["id"]
body = _json_body()
# ---- 闸门 1:不能有别的任务在跑 ----
if os.path.exists(collect.LOCK_PATH):
return jsonify({"ok": False, "error": "busy",
"message": "已有采集任务正在运行,请等它结束后再试"
"(进度见「任务管理」)"}), 409
# ---- 闸门 2:频率 ----
gap = collect.min_interval_seconds(conn)
allowed, wait = security.action_allowed("collect:%d" % uid, gap)
if not allowed:
return jsonify({"ok": False, "error": "too_frequent",
"message": "同一账号两次采集之间需间隔 %d 秒,请 %d 秒后再试"
% (gap, wait)}), 429
frm, to = body.get("from"), body.get("to")
limit = collect.max_range_days(conn)
try:
kw = {}
if frm:
@@ -239,7 +294,16 @@ def api_collect():
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
raise BadParam("起始日期不能晚于结束日期")
r = collect.run_sync(trigger="manual", uid=u["id"], **kw)
# ---- 闸门 3:跨度 ----
if kw.get("from_dt"):
end = kw.get("to_dt") or datetime.now()
days = (end - kw["from_dt"]).days
if days > limit:
raise BadParam(
"采集跨度最长 %d 天,本次请求是 %d 天。"
"请缩小日期范围后分批采集(每次最多 %d 天)。"
% (limit, days, limit))
r = collect.run_sync(trigger="manual", uid=uid, **kw)
except BadParam as e:
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
except collect.Busy as e:
@@ -256,7 +320,7 @@ def api_collect():
"message": str(e)}), code
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"])
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
return jsonify({"ok": True, "result": r})
@@ -307,6 +371,14 @@ def api_maintenance(action):
if action == "vacuum" and not u["is_admin"]:
return jsonify({"ok": False, "error": "forbidden",
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
# 这四类动作都是「整库扫一遍」级别:导出会全表流式扫、补全会连续打云端、
# vacuum 会锁库。各自加一个按账号的最小间隔,挡住脚本循环调用。
hvy = {"fill-prompt": 60, "export-csv": 15, "vacuum": 120, "recount": 5}
if action in hvy:
ok, wait = security.action_allowed("maint:%s:%d" % (action, uid), hvy[action])
if not ok:
return jsonify({"ok": False, "error": "too_frequent",
"message": "该动作刚执行过,请 %d 秒后再试" % wait}), 429
try:
if action == "fill-prompt":
try:
@@ -336,10 +408,118 @@ def api_maintenance(action):
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid)
db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid)
return jsonify({"ok": True, "message": msg})
# ---------------- 备份管理(仅管理员) ----------------
def _backup_error(e):
return jsonify({"ok": False, "error": "backup", "message": str(e)}), 400
@bp.get("/backups")
@admin_required
def api_backups():
conn = db.get_db()
backup.sync_index(conn)
return jsonify({
"items": backup.listing(conn),
"dir": config.BACKUP_DIR,
"total_human": backup.human(backup.total_bytes(conn)),
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last_auto": backup.last_auto_at(conn),
})
@bp.post("/backups")
@admin_required
def api_backup_create():
conn = db.get_db()
u = current_user()
# 打一份整库快照是重活(整库读一遍 + 压缩),别让脚本连打
ok, wait = security.action_allowed("backup:create", 30)
if not ok:
return jsonify({"ok": False, "error": "too_frequent",
"message": "刚打过备份,请 %d 秒后再试" % wait}), 429
note = str(_json_body().get("note") or "").strip()[:200]
try:
r = backup.create(conn, trigger="manual", actor=u["username"], note=note)
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
removed = backup.prune(conn, actor=u["username"])
if removed:
r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed)
r["pruned"] = removed
return jsonify(r)
@bp.get("/backups/<filename>")
@admin_required
def api_backup_download(filename):
"""下载一份归档。文件名必须过 backup.safe_name 的收口。"""
try:
p = backup.path_of(filename)
except backup.BackupError as e:
return _backup_error(e)
if not os.path.exists(p):
return jsonify({"ok": False, "error": "not_found",
"message": "备份文件不存在或已被删除"}), 404
db.audit(db.get_db(), "backup_download", current_user()["username"],
"下载备份 %s" % os.path.basename(p), _ip(), 0)
return send_file(p, as_attachment=True, download_name=os.path.basename(p),
mimetype="application/zip")
@bp.post("/backups/<filename>/restore")
@admin_required
def api_backup_restore(filename):
"""从归档恢复整库。
这是本系统里**破坏性最强**的一个操作:它会把当前所有账号、所有用量、
所有配置替换成归档里的那一份。所以:
* 恢复前自动给当前库打一份 pre-restore 快照(错了能回去)
* 默认把 instance.json 一并恢复(否则 Cookie 密文解不开)
* 完成后所有既有会话失效(密钥与账号可能都变了),必须重新登录
"""
conn = db.get_db()
u = current_user()
body = _json_body()
include_instance = str(body.get("include_instance", "1")).lower() not in ("0", "false", "off", "no")
try:
r = backup.restore(conn, filename, include_instance=include_instance,
actor=u["username"])
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return jsonify(r)
@bp.post("/backups/<filename>/delete")
@admin_required
def api_backup_delete(filename):
conn = db.get_db()
try:
r = backup.delete(conn, filename, actor=current_user()["username"])
except backup.BackupError as e:
return _backup_error(e)
return jsonify(r)
@bp.post("/backups/prune")
@admin_required
def api_backup_prune():
conn = db.get_db()
removed = backup.prune(conn, actor=current_user()["username"])
return jsonify({"ok": True, "removed": removed,
"message": ("已清理 %d 份旧备份" % len(removed)) if removed
else "没有需要清理的备份"})
def _human(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
@@ -363,6 +543,7 @@ def api_settings_get():
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
s["_canEditGlobal"] = bool(u["is_admin"])
s["_canManageBackups"] = bool(u["is_admin"])
s["_role"] = "admin" if u["is_admin"] else "user"
return jsonify(s)
@@ -397,7 +578,7 @@ def api_settings_post():
db.set_secret(conn, "cookie", "", uid)
changed.append(k)
continue
val, err = config.normalize_setting(k, v)
val, err = config.normalize_setting(k, v, conn)
if err:
errors.append(err)
continue
@@ -412,7 +593,7 @@ def api_settings_post():
% "、".join(sorted(denied)))
if errors:
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
request.remote_addr, uid)
_ip(), uid)
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
"errors": errors, "changed": sorted(changed)}), 400
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
@@ -425,8 +606,17 @@ def api_settings_post():
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
for row_uid, skey in stale:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
# 调小时刻数上限后,多余的槽位标记也该跟着清,否则「缩到 2 个时刻」之后
# 另外几个时刻的标记会一直躺在库里,看着像系统还在按旧配置跑。
if "max_schedule_slots_per_day" in changed:
keep = set(scheduler.slots(conn, 0))
for r in conn.execute("SELECT user_id,key FROM settings WHERE key LIKE ?",
(scheduler.SLOT_PREFIX + "%",)).fetchall():
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?",
(r["user_id"], r["key"]))
db.audit(conn, "settings", u["username"],
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
"修改:" + (",".join(sorted(changed)) or "(无变化)"), _ip(), uid)
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
@@ -445,8 +635,16 @@ def api_password():
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(new), u["id"]))
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"])
return jsonify({"ok": True, "message": "密码已更新"})
# 改密即作废**其他**设备的会话:改密码的动机往往就是怀疑它泄漏了,
# 只改散列却留着旧会话,等于给自己一个假的安心。
db.bump_session_ver(conn, u["id"])
# 当前这次会话跟着刷新到新版本,否则用户改完密码立刻被自己踢下线。
# (安全上「全部踢掉」更好,但体验太差会让人不敢改密码。)
with_row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
session["sv"] = db.session_ver_of(with_row)
db.audit(conn, "password", u["username"], "修改登录密码(其他设备会话已失效)",
_ip(), u["id"])
return jsonify({"ok": True, "message": "密码已更新,其他设备上的登录已失效"})
@bp.post("/profile")
@@ -470,7 +668,7 @@ def api_profile():
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
request.remote_addr, u["id"])
_ip(), u["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -514,7 +712,7 @@ def api_user_create():
(body.get("display_name") or name).strip()[:64],
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
db.audit(conn, "user_create", me["username"],
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"])
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), _ip(), me["id"])
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
@@ -561,6 +759,11 @@ def api_user_update(uid):
return jsonify({"ok": False,
"message": "至少要保留一个启用状态的管理员"}), 400
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
if v != "active":
# 停用必须**连会话一起断**:只改 status 的话,对方手上那台设备
# 要到下一个请求才被拦(也不是不行),但版本号一并推掉更干净 ——
# 将来若有人把 current_user 的状态检查挪走,这里还有一道。
db.bump_session_ver(conn, uid)
changed.append("状态→" + ("启用" if v == "active" else "停用"))
pwd = (body.get("password") or "").strip()
if pwd:
@@ -569,12 +772,15 @@ def api_user_update(uid):
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(pwd), uid))
# 管理员重置口令后,该账号在别处的登录必须立刻失效 ——
# 重置口令的典型场景就是「怀疑账号被盗」。
db.bump_session_ver(conn, uid)
changed.append("密码")
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "user_update", me["username"],
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
request.remote_addr, me["id"])
_ip(), me["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -605,7 +811,7 @@ def api_user_delete(uid):
conn.execute("DELETE FROM users WHERE id=?", (uid,))
db.audit(conn, "user_delete", me["username"],
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
request.remote_addr, me["id"])
_ip(), me["id"])
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
+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),仅管理员可见;
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
{% if cur.is_admin %}
<a href="{{ url_for('views.backups_page') }}" class="{{ 'on' if nav=='backups' }}">备份管理</a>
{% endif %}
{% if cur.is_admin %}
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% endif %}
{% if cur.is_admin %}
+24 -2
查看文件
@@ -10,6 +10,8 @@
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
<a class="btn ghost" href="{{ url_for('views.profile_export') }}"
title="导出你的全部用量明细、采集历史、操作审计与有效配置(不含 Cookie 明文)">导出我的全部数据</a>
</div>
</div>
@@ -57,12 +59,32 @@
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
<button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
且不能是常见弱口令。
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
「怀疑被盗所以改密码」才能真正生效。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>导出我的全部数据</h2>
<span class="tag mute">数据可携带</span>
</div>
<p class="hint" style="margin-top:0">
打包下载<b>属于你账号</b>的所有内容:用量明细、采集历史、与你相关的操作审计、
以及账号信息与对你有效的配置。
<br><b>不含</b> Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、
也不含其他任何账号的数据与实例级运行日志。
</p>
<div class="btnrow">
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
</div>
<p class="hint">导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。</p>
</section>
<section class="card">
<div class="cardhead">
<h2>我的采集凭证</h2>
+11 -3
查看文件
@@ -31,7 +31,10 @@
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p>
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
把「每日调度时刻上限」调大。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
@@ -75,11 +78,16 @@
<hr class="sect-divider">
<h3>历史回填</h3>
<form id="formBackfill">
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></label>
<label class="row"><span>起始日期</span>
<input type="date" name="from" max="{{ sch.now[:10] }}"
data-min-days="{{ max_days }}"></label>
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
<button class="btn" type="submit">按区间补采</button>
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p>
<br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
这三条是为了避免把云端接口与本站线程池打满。
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
</form>
</section>
</div>
+213 -6
查看文件
@@ -28,13 +28,16 @@
"""
import csv
import io
import json
import os
import sqlite3
import tempfile
import zipfile
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
request, send_from_directory, session, url_for)
from .. import collect, config, db, query, scheduler, security
from .. import backup, collect, config, db, query, scheduler, security
from ..security import (admin_required, current_user, is_admin, login_required,
safe_next)
@@ -42,7 +45,13 @@ bp = Blueprint("views", __name__)
def _ip():
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
"""客户端地址。**全站统一走 security.client_ip()**。
原来这里直接取 `X-Forwarded-For` 的第 0 段,等于把「来源 IP」交给请求方
自己申报:验证码出图限速、注册配额、登录锁定三道 IP 防线会一起失效。
具体取法与开关见 security.client_ip 的注释。
"""
return security.client_ip()
def _uid():
@@ -104,10 +113,15 @@ def login():
username = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
# 先记一次「尝试」(含成功)。只按失败计数会被「慢慢撞」绕过:
# 攻击者只要把失败次数控制在阈值以下就能无限试。
security.note_try(ip)
left = security.auth_locked(ip, username)
if left:
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip)
flash("登录失败次数过多,请 %d 秒后再试" % left, "error")
reason = security.auth_block_reason(ip, username)
security.audit_login_fail(conn, username,
"已限速(%s),剩余 %d 秒" % (reason, left), ip)
flash(security.auth_block_message(reason, left), "error")
return render_template("login.html",
**_login_ctx(conn, next_url=nxt, username=username,
need_captcha=True)), 429
@@ -127,7 +141,9 @@ def login():
if user is None:
n = security.note_auth_fail(ip, username)
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
flash("%s(剩余尝试 %d 次)" % (err, max(0, config.MAX_LOGIN_FAILS - n)), "error")
# 不再报「剩余 N 次」:用户名维度的计数已经在攻击者手里了,
# 报出来的数字会变成「还差几次就能把这个人锁住」的倒计时。
flash("%s(本来源连续失败 %d 次)" % (err, n), "error")
# 必须把 next 显式回填:失败后 request.args 为空,
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
return render_template("login.html",
@@ -176,9 +192,11 @@ def register():
ctx = _register_ctx(username=username, display_name=display, email=email,
need_captcha=True)
security.note_try(ip)
left = security.auth_locked(ip, username)
if left:
flash("操作过于频繁,请 %d 秒后再试" % left, "error")
reason = security.auth_block_reason(ip, username)
flash(security.auth_block_message(reason, left), "error")
return render_template("register.html", **ctx), 429
# 注册一律要验证码:这是唯一能让陌生人写库的入口
@@ -313,9 +331,14 @@ def tasks():
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
s = db.get_settings(conn, uid=uid)
pages = max(1, (total + size - 1) // size)
# 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位,
# 而不是让用户填完了再吃一个 400/409/429。
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12",
max_slots=s.get("max_schedule_slots_per_day") or "6",
max_days=collect.max_range_days(conn),
min_gap=collect.min_interval_seconds(conn),
can_edit=is_admin(),
page=page, pages=pages, total=total,
page_window=_page_window(page, pages),
@@ -572,6 +595,190 @@ def records_export():
return resp
# ---------------- 备份管理(仅管理员) ----------------
@bp.get("/backups")
@admin_required
def backups_page():
"""备份管理 —— **仅管理员**。
这一页能下载整库归档、也能把整库恢复回某个时刻,权限等价于
「拿到所有人的数据并覆盖它」,所以必须是管理员专属:页面用
@admin_required,接口层另有同样的一层。
"""
conn = db.get_db()
backup.sync_index(conn) # 磁盘才是事实来源,进页面对一次账
rows = backup.listing(conn)
last_auto = backup.last_auto_at(conn)
nxt = backup.next_auto_at(conn)
info = {
"dir": config.BACKUP_DIR,
"count": len([r for r in rows if r["exists"]]),
"total": backup.human(backup.total_bytes(conn)),
"enabled": db.get_bool(conn, "backup_enabled", True),
"interval": db.get_int(conn, "backup_interval_hours", 24),
"keep": db.get_int(conn, "backup_keep", 7),
"last_auto": last_auto or "—",
# 从来没有跑过自动备份时,next_auto_at() 返回的是**当前时间**
# (语义是「马上就轮到它」)。直接印成时间会让人以为那是个已经过去的
# 计划点,所以这里区分成「还没跑过」与「下次某时刻」两种显示。
"next_auto": nxt.strftime("%Y-%m-%d %H:%M:%S") if (nxt and last_auto) else "—",
"never_auto": not last_auto,
"db_bytes": backup.human(os.path.getsize(config.SQLITE_PATH)
if os.path.exists(config.SQLITE_PATH) else 0),
}
s = db.get_settings(conn)
return render_template("backups.html", rows=rows, info=info, s=s,
num_settings=config.NUM_SETTINGS, active="backups")
# ---------------- 个人数据导出(每个账号都能导自己的) ----------------
def _zip_stream(buf, filename, mimetype="application/zip"):
"""把已生成好的临时缓冲流给浏览器,并在流结束后关掉它。"""
from flask import Response
def gen():
try:
buf.seek(0)
while True:
chunk = buf.read(65536)
if not chunk:
break
yield chunk
finally:
try:
buf.close()
except Exception: # noqa: BLE001
pass
resp = Response(gen(), mimetype=mimetype,
headers={"Content-Disposition": 'attachment; filename="%s"' % filename})
resp.headers["X-Accel-Buffering"] = "no"
return resp
@bp.get("/profile/export")
@login_required
def profile_export():
"""导出「我的全部数据」。
这是普通账号的数据可携带出口,所以**只含本人的数据**,且
**绝不含 Cookie 明文**(只写「有没有配、多少字符、什么时候更新的」)。
归档里放使用记录、采集历史、本人审计与本人配置四份,另加一份说明。
"""
u = current_user()
uid = u["id"]
ok, wait = security.action_allowed("export:%d" % uid, 10)
if not ok:
return render_template("error.html", code=429,
message="导出太频繁了,请 %d 秒后再试" % wait), 429
conn = db.get_db()
buf = tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024)
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
# ---- 1. 使用记录(与官网 xlsx、CSV 导出同构的列)----
with z.open("使用记录.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(collect.FIELDS)
f.write(b.getvalue().encode("utf-8"))
for r in query.iter_records(conn, uid, None, None):
b.seek(0)
b.truncate(0)
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
r["model"], r["client"], r["ts"]])
f.write(b.getvalue().encode("utf-8"))
# ---- 2. 采集历史 ----
with z.open("采集历史.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(["id", "触发方式", "状态", "开始", "结束", "耗时ms",
"窗口起", "窗口止", "云端返回", "新增", "重复", "存档总数",
"冲突", "结论"])
for r in conn.execute(
"SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
"win_to,fetched,added,dup,total,conflicts,message FROM collect_runs"
" WHERE user_id=? ORDER BY id", (uid,)):
b.seek(0)
b.truncate(0)
w.writerow(list(r))
f.write(b.getvalue().encode("utf-8"))
# ---- 3. 本人相关的操作审计 ----
with z.open("操作审计.csv", "w") as f:
f.write("\ufeff".encode("utf-8"))
b = io.StringIO()
w = csv.writer(b, lineterminator="\r\n")
w.writerow(["时间", "操作者", "动作", "说明", "来源 IP"])
for r in conn.execute(
"SELECT at,actor,action,detail,ip FROM audit_log"
" WHERE user_id=? ORDER BY id", (uid,)):
b.seek(0)
b.truncate(0)
w.writerow(list(r))
f.write(b.getvalue().encode("utf-8"))
# ---- 4. 账号与有效配置(凭证只回状态)----
row = db.user_by_id(conn, uid)
st = db.secret_state(conn, "cookie", uid)
cfg = db.get_settings(conn, uid=uid)
for k in [k for k in list(cfg) if config.is_internal_key(k)]:
cfg.pop(k, None)
cfg.pop("cookie", None)
snaps = {k: v for k, v in cfg.items()
if not (isinstance(v, str) and len(v) > 200)}
payload = {
"导出时间": db.now_str(),
"程序版本": _app_version(),
"账号": {
"id": row["id"], "用户名": row["username"],
"显示名": row["display_name"], "邮箱": row["email"],
"角色": "管理员" if row["is_admin"] else "普通账号",
"状态": row["status"], "注册时间": row["created_at"],
"注册来源 IP": row["register_ip"],
"最后登录": row["last_login_at"], "登录次数": row["login_count"],
},
"数据量": {
"记录条数": conn.execute("SELECT COUNT(*) FROM usage_records"
" WHERE user_id=?", (uid,)).fetchone()[0],
"积分合计": round(conn.execute(
"SELECT COALESCE(SUM(credits),0) FROM usage_records"
" WHERE user_id=?", (uid,)).fetchone()[0], 2),
},
"凭证状态": {
"Cookie": ("已配置 %d 字符,尾部 …%s" % (st["chars"], st["tail"])) if st["set"]
else ("无法解密" if st["broken"] else "未配置"),
"说明": "出于安全考虑,导出文件里不含 Cookie 明文;如需迁移请到「配置管理」重新粘贴。",
},
"有效配置": snaps,
}
with z.open("我的账号与配置.json", "w") as f:
f.write(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"))
with z.open("说明.txt", "w") as f:
f.write(("本归档是账号「%s」在本站的全部数据副本。\n\n"
"包含:\n"
" 使用记录.csv —— 你的全部用量明细(与官网导出同构)\n"
" 采集历史.csv —— 你的采集任务运行历史\n"
" 操作审计.csv —— 与你账号相关的操作记录\n"
" 我的账号与配置.json —— 账号信息与对你有有效的配置\n\n"
"不包含:Cookie 明文、任何他人的数据、实例级运行日志。\n"
"导出时间:%s\n程序版本:%s\n"
% (u["username"], db.now_str(), _app_version())).encode("utf-8"))
name = "my-data_%s_%s.zip" % (u["username"], db.now_str()[:10])
db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name,
_ip(), uid)
return _zip_stream(buf, name)
def _app_version():
from .. import __version__
return __version__
# ---------------- 兼容旧地址 ----------------
@bp.get("/index.html")
def legacy_index():