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 行删除
+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"]})