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 配置
这个提交包含在:
@@ -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()
|
||||
|
||||
在新工单中引用
屏蔽一个用户