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 行删除
+152 -3
查看文件
@@ -28,6 +28,9 @@
python manage.py passwd <用户名> --role admin 新建或提权为管理员
python manage.py status 查看各账号的调度与最近采集状态
python manage.py vacuum 整理数据库(checkpoint + VACUUM)
python manage.py backup 立即打一份备份(所有 *.sqlite + instance.json)
python manage.py backups 列出备份;--prune 按保留份数清理最旧的
python manage.py restore <文件名> --yes 从备份恢复(恢复前会自动再备份一份当前库)
"""
import argparse
import os
@@ -35,7 +38,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402
from workbuddy_portal import backup, client, collect, config, db, query, scheduler # noqa: E402
from workbuddy_portal import security # noqa: E402
@@ -102,6 +105,19 @@ def cmd_init(args):
_p(" 账号数:%d" % db.user_count(conn))
_p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 操作账号:%s" % args.user)
# 随机口令只在「本次进程刚生成」时返回 —— 重跑 init 时管理员已存在,这里是 None。
# 必须**明着打印**:库里只有散列,日志一旦滚掉就再也拿不回来了。
pwd = db.generated_admin_password()
if pwd:
_p("")
_p(" ================= 管理员初始口令 =================")
_p(" 用 户 名:%s" % args.user)
_p(" 初 始 口 令:%s" % pwd)
_p("")
_p(" 这是随机生成的(1.4.0 起不再有 admin123 这类默认口令)。")
_p(" 程序只打印这一次、数据库里只存散列 —— 找不回来,")
_p(" 请立刻抄走,并在登录后到「个人设置」改掉。")
_p(" ==================================================")
st = db.secret_state(conn, "cookie", uid)
if st["broken"]:
_p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴")
@@ -119,8 +135,11 @@ def cmd_serve(args):
return
try:
from waitress import serve
_p("生产模式(waitress)监听 http://%s:%d" % (host, port))
serve(app, host=host, port=port, threads=8, ident="workbuddy-portal")
# 线程数是资源上限的一部分(见 config.THREADS 的注释):
# 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复)。
_p("生产模式(waitress)监听 http://%s:%d,线程数 %d"
% (host, port, config.THREADS))
serve(app, host=host, port=port, threads=config.THREADS, ident="workbuddy-portal")
except ImportError:
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
app.run(host=host, port=port, threaded=True)
@@ -474,6 +493,120 @@ def cmd_vacuum(args):
return 0
# ---------------- 备份 / 恢复 ----------------
def cmd_backup(args):
"""立即打一份备份。走 SQLite 在线备份 API,采集正在写也安全。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
try:
r = backup.create(conn, trigger="cli", actor="cli", note=args.note or "")
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
_p(r["message"])
_p(" 归档:%s" % backup.path_of(r["filename"]))
_p(" 大小:%s" % backup.human(r["bytes"]))
_p(" 校验:sha256 %s…" % r["sha256"][:16])
_p(" 内容:%d 条记录 / %.2f 积分 / %d 个账号 / 库结构 uv=%s"
% (r["stats"]["records"], r["stats"]["credits"],
r["stats"]["users"], r["stats"]["schema_ver"]))
keep = db.get_int(conn, "backup_keep", 7)
removed = backup.prune(conn, keep=keep, actor="cli")
if removed:
_p(" 已按「保留 %d 份」清理 %d 份最旧的:%s"
% (keep, len(removed), ", ".join(removed)))
_p("")
_p("提示:归档里含 instance.json(SECRET_KEY 与 cookie_key),")
_p(" 权限等同管理员口令 —— 别随镜像 / 仓库分发,也别放进公开网盘。")
finally:
conn.close()
return 0
def cmd_backups(args):
"""列出备份(可选清理)。磁盘是事实来源,每次先重建索引。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
n = backup.sync_index(conn)
rows = backup.listing(conn)
_p("备份目录:%s" % backup.backup_dir())
if not rows:
_p("磁盘上还没有任何归档(%d 份 zip)。" % n)
_p("跑 manage.py backup 打一份;容器里这个目录挂的是独立的 wb_backups 卷。")
return 0
_p("磁盘 %d 份 · 合计 %s" % (n, backup.human(backup.total_bytes(conn))))
_p("")
_p("%-3s %-30s %10s %7s %11s %5s %-11s %s"
% ("#", "文件名", "大小", "条数", "积分", "账号", "来源", "生成时间"))
for i, r in enumerate(rows, 1):
_p("%-3d %-30s %10s %7d %11.2f %5d %-11s %s%s"
% (i, r["filename"], r["size_h"], r["records"], r["credits"],
r["users"], r["trigger"] or "-", r["created_at"],
"" if r["exists"] else " [文件已不存在]"))
enabled = db.get_bool(conn, "backup_enabled", True)
every = db.get_int(conn, "backup_interval_hours", 24)
keep = db.get_int(conn, "backup_keep", 7)
nxt = backup.next_auto_at(conn)
tail = (",下次约 %s" % nxt.strftime("%Y-%m-%d %H:%M")) if nxt else ""
_p("")
_p("自动备份:%s(周期 %d 小时,保留 %d 份)%s"
% ("启用" if enabled else "停用", every, keep, tail))
if args.prune:
want = args.keep or keep
removed = backup.prune(conn, keep=want, actor="cli")
_p("已按保留 %d 份清理 %d 份:%s"
% (want, len(removed), ", ".join(removed) if removed else "无(本来就不够多)"))
finally:
conn.close()
return 0
def cmd_restore(args):
"""从归档恢复。**破坏性操作**:必须显式加 --yes。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
try:
info = backup.verify(backup.path_of(args.filename))
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
cn, cc = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
cu = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
_p("待恢复:%s" % args.filename)
_p(" 归档生成于 %s(程序 v%s,库结构 uv=%d)"
% (info["created_at"], info["version"], info["schema_ver"]))
_p(" 归档内容:%d 条 / %.2f 积分 / %d 个账号"
% (info["records"], info["credits"], info["users"]))
_p(" 当前正本:%d 条 / %.2f 积分 / %d 个账号" % (cn, cc, cu))
if not args.yes:
_p("")
_p("这会**整表替换** usage_records / settings / users / collect_runs / "
"audit_log / captchas,")
_p("并且所有既有登录会话会立即失效(所有人需要重新登录)。")
_p("恢复前系统会自动把当前库另存一份备份,所以恢复错了还能回来。")
_p("")
_p("确认无误后,重跑并加上 --yes。")
return 1
try:
r = backup.restore(conn, args.filename,
include_instance=not args.no_instance, actor="cli")
except backup.BackupError as e:
_p("[error] %s" % e)
return 2
_p(r["message"])
_p(" 搬运的表:%s" % ", ".join(r["moved"]))
_p(" instance.json:%s"
% ("已一并恢复(cookie_key 换成了归档里那把)" if r["restored_instance"]
else "未动(保留本机当前的密钥)"))
finally:
conn.close()
return 0
def _human(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
@@ -552,6 +685,22 @@ def main():
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
s.set_defaults(func=cmd_vacuum)
s = sub.add_parser("backup", help="立即打一份备份(所有 *.sqlite + instance.json)")
s.add_argument("--note", default="", help="给这份备份写一句备注(记进 manifest 与审计)")
s.set_defaults(func=cmd_backup)
s = sub.add_parser("backups", help="列出备份;--prune 按保留份数清理最旧的")
s.add_argument("--prune", action="store_true", help="顺便清理超出保留份数的旧备份")
s.add_argument("--keep", type=int, default=None, help="保留几份(默认取配置里的值)")
s.set_defaults(func=cmd_backups)
s = sub.add_parser("restore", help="从备份恢复(破坏性操作,必须加 --yes)")
s.add_argument("filename", help="备份文件名(如 usage-20260916-151043.zip,用 backups 查看)")
s.add_argument("--no-instance", action="store_true",
help="不恢复 instance.json(保留本机当前的 secret_key / cookie_key)")
s.add_argument("--yes", action="store_true", help="确认执行(不加只打印将要发生什么)")
s.set_defaults(func=cmd_restore)
args = ap.parse_args()
if not getattr(args, "func", None):
ap.print_help()