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