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 行删除
+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():