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 配置
这个提交包含在:
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
|
||||
|
||||
from . import config, db, security
|
||||
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.4.0"
|
||||
PROJECT_NAME = config.PROJECT_NAME
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个:
|
||||
* 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧)
|
||||
* 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉
|
||||
* 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路
|
||||
|
||||
本模块的三条设计决定
|
||||
--------------------
|
||||
1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。
|
||||
它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是
|
||||
一个「某一时刻的完整库」。手工 cp 做不到这一点。
|
||||
2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选
|
||||
`instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复;
|
||||
而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开
|
||||
settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
|
||||
代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore /
|
||||
.dockerignore 与 docs/DEPLOYMENT.md)。
|
||||
3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移
|
||||
到当前 schema,然后在**一个写事务**里整表搬过去。这样:
|
||||
* 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件
|
||||
swap 会因句柄占用直接失败);
|
||||
* 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完;
|
||||
* 中途失败就是一个事务回滚,不会留下半个库。
|
||||
|
||||
恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。
|
||||
|
||||
恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话
|
||||
立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好
|
||||
相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import collect, config, db
|
||||
|
||||
log = logging.getLogger("wb.backup")
|
||||
|
||||
SUFFIX = ".zip"
|
||||
MANIFEST = "manifest.json"
|
||||
INSTANCE_NAME = "instance.json"
|
||||
FORMAT_VERSION = 1
|
||||
|
||||
# 恢复时整表搬运的表清单。
|
||||
# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」,
|
||||
# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目
|
||||
# (而按需重建索引是幂等的,见 sync_index)。
|
||||
RESTORE_TABLES = ("users", "settings", "usage_records",
|
||||
"collect_runs", "audit_log", "captchas")
|
||||
|
||||
|
||||
class BackupError(Exception):
|
||||
"""备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。"""
|
||||
|
||||
|
||||
# ---------------- 路径与文件名 ----------------
|
||||
def backup_dir():
|
||||
config.ensure_dirs()
|
||||
return config.BACKUP_DIR
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
"""把用户传来的文件名收敛成「备份目录下的一个 zip」。
|
||||
|
||||
必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 ——
|
||||
下载与恢复接口都直接吃文件名,这里是唯一的收口点。
|
||||
"""
|
||||
base = os.path.basename(str(name or "").strip().replace("\\", "/"))
|
||||
if not base or base in (".", "..") or not base.endswith(SUFFIX):
|
||||
raise BackupError("备份文件名不合法")
|
||||
if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-"
|
||||
for c in base):
|
||||
raise BackupError("备份文件名含非法字符")
|
||||
return base
|
||||
|
||||
|
||||
def path_of(name):
|
||||
return os.path.join(backup_dir(), safe_name(name))
|
||||
|
||||
|
||||
def human(n):
|
||||
n = float(n or 0)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024 or unit == "GB":
|
||||
return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit))
|
||||
n /= 1024.0
|
||||
|
||||
|
||||
def _app_version():
|
||||
from . import __version__ # 延迟导入,避开包初始化顺序
|
||||
return __version__
|
||||
|
||||
|
||||
# ---------------- 元数据 ----------------
|
||||
def _db_files():
|
||||
"""数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。"""
|
||||
out = []
|
||||
d = config.DATA_DIR
|
||||
if not os.path.isdir(d):
|
||||
return out
|
||||
for fn in sorted(os.listdir(d)):
|
||||
if not fn.endswith(".sqlite"):
|
||||
continue
|
||||
p = os.path.join(d, fn)
|
||||
if os.path.isfile(p):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _snapshot(src_path, dst_path):
|
||||
"""用在线备份 API 生成一致快照(src 有并发写也安全)。"""
|
||||
src = sqlite3.connect(src_path, timeout=8.0)
|
||||
try:
|
||||
dst = sqlite3.connect(dst_path)
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
finally:
|
||||
src.close()
|
||||
|
||||
|
||||
def _stats():
|
||||
"""归档时的关键计数,用于「挑一份恢复」与事后核对。"""
|
||||
p = config.SQLITE_PATH
|
||||
if not os.path.exists(p):
|
||||
return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True)
|
||||
try:
|
||||
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
|
||||
" FROM usage_records").fetchone()
|
||||
u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _extract_safely(zf, dest, names):
|
||||
"""只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。
|
||||
|
||||
`ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外,
|
||||
而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。
|
||||
"""
|
||||
for n in names:
|
||||
base = os.path.basename(n.replace("\\", "/"))
|
||||
if not base or base in (".", ".."):
|
||||
continue
|
||||
with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst:
|
||||
shutil.copyfileobj(fsrc, fdst)
|
||||
|
||||
|
||||
# ---------------- 生成备份 ----------------
|
||||
def create(conn, trigger="manual", actor=None, note=""):
|
||||
"""打一份新备份,返回结果 dict。失败抛 BackupError。"""
|
||||
dbs = _db_files()
|
||||
if not dbs:
|
||||
raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的")
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
name = "usage-%s%s" % (stamp, SUFFIX)
|
||||
dest = os.path.join(backup_dir(), name)
|
||||
seq = 1
|
||||
while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖
|
||||
name = "usage-%s-%d%s" % (stamp, seq, SUFFIX)
|
||||
dest = os.path.join(backup_dir(), name)
|
||||
seq += 1
|
||||
|
||||
st = _stats()
|
||||
tmpdir = tempfile.mkdtemp(prefix="wb-backup-")
|
||||
files_meta = []
|
||||
try:
|
||||
for i, src in enumerate(dbs):
|
||||
fn = os.path.basename(src)
|
||||
# 主库用在线备份 API;其余库(当前没有,但口径要留全)同理
|
||||
snap = os.path.join(tmpdir, fn)
|
||||
_snapshot(src, snap)
|
||||
files_meta.append({"name": fn, "bytes": os.path.getsize(snap),
|
||||
"sha256": _sha256(snap), "primary": src == config.SQLITE_PATH})
|
||||
inst = config.INSTANCE_FILE
|
||||
if os.path.exists(inst):
|
||||
shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME))
|
||||
files_meta.append({"name": INSTANCE_NAME,
|
||||
"bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)),
|
||||
"sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)),
|
||||
"secret": True})
|
||||
manifest = {
|
||||
"format": FORMAT_VERSION,
|
||||
"app": config.PROJECT_NAME,
|
||||
"version": _app_version(),
|
||||
"created_at": db.now_str(),
|
||||
"trigger": trigger,
|
||||
"actor": actor or "",
|
||||
"note": note or "",
|
||||
"stats": st,
|
||||
"files": files_meta,
|
||||
"restore_note": ("恢复会整表替换 usage_records / settings / users 等;"
|
||||
"恢复前系统会自动先备份当前库。"),
|
||||
}
|
||||
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
for fn in [f["name"] for f in files_meta]:
|
||||
z.write(os.path.join(tmpdir, fn), fn)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
size = os.path.getsize(dest)
|
||||
digest = _sha256(dest)
|
||||
db.audit(conn, "backup_create", actor or "system",
|
||||
"生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]),
|
||||
"127.0.0.1", 0)
|
||||
return {"ok": True, "filename": name, "bytes": size, "sha256": digest,
|
||||
"trigger": trigger, "stats": st,
|
||||
"message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])}
|
||||
|
||||
|
||||
# ---------------- 索引(表 <- 磁盘) ----------------
|
||||
def _row_exists(conn, filename):
|
||||
return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None
|
||||
|
||||
|
||||
def sync_index(conn):
|
||||
"""把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。
|
||||
|
||||
手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存,
|
||||
**磁盘才是事实来源**,所以这里做双向对齐而不是只信表。
|
||||
"""
|
||||
files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)]
|
||||
for fn in files:
|
||||
if _row_exists(conn, fn):
|
||||
continue
|
||||
p = os.path.join(backup_dir(), fn)
|
||||
meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
|
||||
created = ""
|
||||
# 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。
|
||||
# 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是
|
||||
# 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。
|
||||
trig = "external"
|
||||
actor = None
|
||||
note = "从磁盘发现"
|
||||
try:
|
||||
info = read_manifest(p)
|
||||
meta = dict(meta, **info.get("stats", {}))
|
||||
created = info.get("created_at") or ""
|
||||
trig = (info.get("trigger") or "").strip() or "external"
|
||||
actor = (info.get("actor") or "").strip() or None
|
||||
note = (info.get("note") or "").strip() or note
|
||||
except BackupError:
|
||||
pass
|
||||
if not created:
|
||||
# 读不出 manifest 就用文件时间,至少让排序有意义
|
||||
try:
|
||||
created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except OSError:
|
||||
created = db.now_str()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor,"
|
||||
" schema_ver,records,credits,users,note,missing)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,0)",
|
||||
(fn, os.path.getsize(p), "", created, trig, actor,
|
||||
meta.get("schema_ver", 0), meta.get("records", 0),
|
||||
meta.get("credits", 0.0), meta.get("users", 0), note))
|
||||
# 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责
|
||||
for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall():
|
||||
gone = not os.path.exists(os.path.join(backup_dir(), r["filename"]))
|
||||
want = 1 if gone else 0
|
||||
if r["missing"] != want:
|
||||
conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"]))
|
||||
return len(files)
|
||||
|
||||
|
||||
def listing(conn):
|
||||
"""备份清单(新→旧),附磁盘实际大小。"""
|
||||
out = []
|
||||
for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"):
|
||||
p = os.path.join(backup_dir(), r["filename"])
|
||||
exists = os.path.exists(p)
|
||||
d = dict(r)
|
||||
d["exists"] = exists
|
||||
if exists:
|
||||
d["bytes"] = os.path.getsize(p)
|
||||
d["size_h"] = human(d["bytes"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def total_bytes(conn):
|
||||
return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0]
|
||||
|
||||
|
||||
# ---------------- 校验 ----------------
|
||||
def _name_map(zf):
|
||||
"""归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。"""
|
||||
m = {}
|
||||
for n in zf.namelist():
|
||||
b = os.path.basename(n.replace("\\", "/"))
|
||||
if b:
|
||||
m[b] = n
|
||||
return m
|
||||
|
||||
|
||||
def read_manifest(path):
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
entry = _name_map(z).get(MANIFEST)
|
||||
if entry is None:
|
||||
raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST)
|
||||
return json.loads(z.read(entry).decode("utf-8"))
|
||||
except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e:
|
||||
raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e))
|
||||
|
||||
|
||||
def verify(path):
|
||||
"""校验一份归档是否可用于恢复(不修改任何东西)。"""
|
||||
if not os.path.exists(path):
|
||||
raise BackupError("备份文件不存在或已被删除")
|
||||
man = read_manifest(path)
|
||||
if int(man.get("format") or 0) > FORMAT_VERSION:
|
||||
raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序"
|
||||
% (man.get("format"), FORMAT_VERSION))
|
||||
files = man.get("files") or []
|
||||
primary = [f for f in files if f.get("primary")] or (files[:1] if files else [])
|
||||
if not primary:
|
||||
raise BackupError("归档里没有数据库文件")
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wb-verify-")
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
nm = _name_map(z)
|
||||
for f in files:
|
||||
fn = os.path.basename(str(f.get("name") or ""))
|
||||
if not fn:
|
||||
raise BackupError("归档条目名不合法:%s" % f.get("name"))
|
||||
if fn not in nm:
|
||||
raise BackupError("归档缺少文件:%s" % f.get("name"))
|
||||
_extract_safely(z, tmpdir, [nm[fn]])
|
||||
db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
|
||||
if not os.path.exists(db_path):
|
||||
raise BackupError("归档里的主数据库文件解不出来")
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True)
|
||||
try:
|
||||
integ = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
|
||||
" FROM usage_records").fetchone()
|
||||
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
raise BackupError("归档里的数据库打不开:%s" % e)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
if integ != "ok":
|
||||
raise BackupError("归档里的数据库完整性检查未通过:%s" % integ)
|
||||
if uv > db.DB_SCHEMA_VERSION:
|
||||
raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复"
|
||||
% (uv, db.DB_SCHEMA_VERSION))
|
||||
return {"manifest": man, "integrity": integ, "schema_ver": uv,
|
||||
"records": n, "credits": round(cr or 0, 2), "users": users,
|
||||
"created_at": man.get("created_at") or "",
|
||||
"version": man.get("version") or ""}
|
||||
|
||||
|
||||
# ---------------- 恢复 ----------------
|
||||
def _table_cols(conn, db_name, table):
|
||||
return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))]
|
||||
|
||||
|
||||
def _copy_tables(conn, src_path):
|
||||
"""把 src 库里的业务表整表搬到主库,一个事务内完成。
|
||||
|
||||
用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库
|
||||
建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默
|
||||
错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。
|
||||
"""
|
||||
conn.execute("ATTACH DATABASE ? AS src", (src_path,))
|
||||
try:
|
||||
plan = []
|
||||
for t in RESTORE_TABLES:
|
||||
dst_cols = _table_cols(conn, "main", t)
|
||||
src_cols = _table_cols(conn, "src", t)
|
||||
if not src_cols:
|
||||
continue # 归档里没有这张表(更老的版本)
|
||||
common = [c for c in dst_cols if c in src_cols]
|
||||
if not common:
|
||||
continue
|
||||
plan.append((t, common))
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
for t, cols in plan:
|
||||
cl = ",".join('"%s"' % c for c in cols)
|
||||
conn.execute("DELETE FROM main.%s" % t)
|
||||
conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s"
|
||||
% (t, cl, cl, t))
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
return [t for t, _ in plan]
|
||||
finally:
|
||||
conn.execute("DETACH DATABASE src")
|
||||
|
||||
|
||||
def _restore_instance(src_file):
|
||||
"""把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。
|
||||
|
||||
必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失,
|
||||
而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。
|
||||
"""
|
||||
dst = config.INSTANCE_FILE
|
||||
if os.path.exists(dst):
|
||||
bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S"))
|
||||
try:
|
||||
shutil.copy2(dst, bak)
|
||||
except OSError as e:
|
||||
raise BackupError("备份 instance.json 失败,已中止:%s" % e)
|
||||
try:
|
||||
shutil.copy2(src_file, dst)
|
||||
if os.name == "posix":
|
||||
os.chmod(dst, 0o600)
|
||||
except OSError as e:
|
||||
raise BackupError("写入 instance.json 失败:%s" % e)
|
||||
return True
|
||||
|
||||
|
||||
def restore(conn, filename, include_instance=True, actor=None):
|
||||
"""从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。
|
||||
|
||||
副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效**
|
||||
(恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。
|
||||
|
||||
返回结果 dict;失败抛 BackupError。
|
||||
"""
|
||||
path = path_of(filename)
|
||||
info = verify(path) # 先验,验不过就不动任何东西
|
||||
|
||||
# 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。
|
||||
try:
|
||||
safety = create(conn, trigger="pre-restore", actor=actor or "system",
|
||||
note="恢复 %s 之前的自动快照" % filename)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wb-restore-")
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
_extract_safely(z, tmpdir, z.namelist())
|
||||
primary = [f for f in info["manifest"]["files"] if f.get("primary")]
|
||||
if not primary:
|
||||
primary = [info["manifest"]["files"][0]]
|
||||
src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
|
||||
if not os.path.exists(src_path):
|
||||
raise BackupError("归档里缺少主数据库文件")
|
||||
|
||||
# 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复,
|
||||
# 而且迁移失败时正本一个字节都没动。
|
||||
tmp_conn = sqlite3.connect(src_path)
|
||||
try:
|
||||
tmp_conn.row_factory = sqlite3.Row
|
||||
db.init_db(conn=tmp_conn, create_admin=False)
|
||||
finally:
|
||||
tmp_conn.close()
|
||||
# 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。
|
||||
# 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。
|
||||
tmp_conn = sqlite3.connect(src_path)
|
||||
try:
|
||||
tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
tmp_conn.execute("PRAGMA journal_mode=DELETE")
|
||||
finally:
|
||||
tmp_conn.close()
|
||||
|
||||
# 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。
|
||||
with collect._Lock():
|
||||
moved = _copy_tables(conn, src_path)
|
||||
# 数据整表换过了 —— 让**所有**会话立即失效。
|
||||
#
|
||||
# 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在
|
||||
# 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值,
|
||||
# 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经
|
||||
# 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。
|
||||
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1")
|
||||
inst = False
|
||||
if include_instance:
|
||||
inst_file = os.path.join(tmpdir, INSTANCE_NAME)
|
||||
if os.path.exists(inst_file):
|
||||
inst = _restore_instance(inst_file)
|
||||
out = _stats()
|
||||
out["moved"] = moved
|
||||
out["instance"] = inst
|
||||
except collect.Busy as e:
|
||||
raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e)
|
||||
except sqlite3.Error as e:
|
||||
raise BackupError("恢复过程中数据库报错,已回滚:%s" % e)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
db.audit(conn, "backup_restore", actor or "system",
|
||||
"从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)"
|
||||
% (filename, out["records"], out["credits"], out["users"],
|
||||
",含 instance.json" if out["instance"] else "",
|
||||
safety.get("filename")),
|
||||
"127.0.0.1", 0)
|
||||
return {
|
||||
"ok": True, "filename": filename, "safety_backup": safety.get("filename"),
|
||||
"moved": out["moved"], "restored_instance": out["instance"],
|
||||
"stats": out, "message":
|
||||
"已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。"
|
||||
"恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。"
|
||||
% (filename, out["records"], out["credits"], out["users"],
|
||||
safety.get("filename")),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 删除 / 清理 ----------------
|
||||
def delete(conn, filename, actor=None):
|
||||
"""删除一份备份(文件 + 索引行)。"""
|
||||
name = safe_name(filename)
|
||||
p = path_of(name)
|
||||
if not os.path.exists(p):
|
||||
conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,))
|
||||
raise BackupError("备份文件不存在(可能已被手工删除)")
|
||||
os.remove(p)
|
||||
conn.execute("DELETE FROM backups WHERE filename=?", (name,))
|
||||
db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0)
|
||||
return {"ok": True, "filename": name, "message": "已删除备份 " + name}
|
||||
|
||||
|
||||
def prune(conn, keep=None, actor=None):
|
||||
"""按「保留份数」清理最旧的备份。返回删除清单。"""
|
||||
if keep is None:
|
||||
keep = db.get_int(conn, "backup_keep", 7)
|
||||
keep = max(1, min(100, int(keep or 1)))
|
||||
rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall()
|
||||
# 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额
|
||||
alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))]
|
||||
victims = alive[keep:]
|
||||
removed = []
|
||||
for r in victims:
|
||||
try:
|
||||
os.remove(os.path.join(backup_dir(), r["filename"]))
|
||||
conn.execute("DELETE FROM backups WHERE id=?", (r["id"],))
|
||||
removed.append(r["filename"])
|
||||
except OSError as e:
|
||||
log.warning("清理旧备份 %s 失败:%s", r["filename"], e)
|
||||
if removed:
|
||||
db.audit(conn, "backup_prune", actor or "system",
|
||||
"按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)),
|
||||
"127.0.0.1", 0)
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------- 自动备份 ----------------
|
||||
def last_auto_at(conn):
|
||||
r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')"
|
||||
).fetchone()
|
||||
return (r[0] if r and r[0] else "")
|
||||
|
||||
|
||||
def due(conn, now=None):
|
||||
"""自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。"""
|
||||
if not db.get_bool(conn, "backup_enabled", True):
|
||||
return False, 0.0, 0
|
||||
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
|
||||
last = last_auto_at(conn)
|
||||
now = now or datetime.now()
|
||||
if not last:
|
||||
return True, -1.0, every
|
||||
try:
|
||||
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return True, -1.0, every
|
||||
hrs = (now - dt).total_seconds() / 3600.0
|
||||
return hrs >= every, hrs, every
|
||||
|
||||
|
||||
def maybe_auto(conn, now=None):
|
||||
"""调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。"""
|
||||
ok, hrs, every = due(conn, now)
|
||||
if not ok:
|
||||
return None
|
||||
try:
|
||||
r = create(conn, trigger="auto", actor="system",
|
||||
note="自动备份(周期 %d 小时)" % every)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("自动备份失败:%s", e)
|
||||
return None
|
||||
removed = prune(conn, actor="system")
|
||||
if removed:
|
||||
log.info("自动备份后清理旧备份 %d 份", len(removed))
|
||||
log.info("自动备份完成:%s", r["message"])
|
||||
return r
|
||||
|
||||
|
||||
def next_auto_at(conn, now=None):
|
||||
"""下一次自动备份时间(给界面显示)。"""
|
||||
if not db.get_bool(conn, "backup_enabled", True):
|
||||
return None
|
||||
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
|
||||
last = last_auto_at(conn)
|
||||
now = now or datetime.now()
|
||||
if not last:
|
||||
return now
|
||||
try:
|
||||
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return now
|
||||
nxt = dt + timedelta(hours=every)
|
||||
return nxt if nxt > now else now
|
||||
|
||||
+113
-32
@@ -18,11 +18,20 @@
|
||||
|
||||
字模
|
||||
----
|
||||
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
|
||||
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
|
||||
5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移,
|
||||
笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。
|
||||
|
||||
强度说明(为什么要这么多花样)
|
||||
------------------------------
|
||||
字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。
|
||||
在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上
|
||||
尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。
|
||||
纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。
|
||||
反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 ——
|
||||
验证码是「提高成本」而不是「杜绝」。
|
||||
"""
|
||||
import hmac
|
||||
import os
|
||||
import math
|
||||
import random
|
||||
import secrets
|
||||
import struct
|
||||
@@ -140,54 +149,126 @@ class _Canvas:
|
||||
return bytes(self.buf)
|
||||
|
||||
|
||||
def _brush_line(cv, x0, y0, x1, y1, color, r):
|
||||
"""用 r×r 方刷画一条线。
|
||||
|
||||
旋转后的笔画如果只用点阵格逐个平移,会出现锯齿状断点 —— 一圈一圈的
|
||||
缝隙正好给「连通域分析」留了把手。这里改成沿线段走样并盖方刷,
|
||||
笔画连续,旋转也不散架。
|
||||
"""
|
||||
steps = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
|
||||
o = r // 2
|
||||
for i in range(steps + 1):
|
||||
t = i / float(steps)
|
||||
x = int(round(x0 + (x1 - x0) * t))
|
||||
y = int(round(y0 + (y1 - y0) * t))
|
||||
cv.rect(x - o, y - o, r, r, color)
|
||||
|
||||
|
||||
def _draw_char(cv, glyph, cx, cy, scale, color, rng):
|
||||
"""在 (cx, cy) 为中心画一个字符:随机旋转 + 切变 + 波浪 + 粗笔画。
|
||||
|
||||
三段变换按「点阵坐标 -> 缩放居中 -> 切变 -> 旋转 -> 波浪纵向偏移」依次施加。
|
||||
顺序不能乱:先切变再旋转,得到的才是「斜着写的手写体」而不是「被斜切的旋转体」。
|
||||
"""
|
||||
ang = rng.uniform(-0.38, 0.38) # 弧度,约 ±22°
|
||||
cos_a, sin_a = math.cos(ang), math.sin(ang)
|
||||
shear = rng.uniform(-0.32, 0.32)
|
||||
amp = rng.uniform(0.0, 2.6) # 波浪振幅(像素)
|
||||
period = rng.uniform(18.0, 42.0)
|
||||
phase = rng.uniform(0.0, 6.283)
|
||||
half_w = GLYPH_W * scale / 2.0
|
||||
half_h = GLYPH_H * scale / 2.0
|
||||
r = max(2, scale)
|
||||
|
||||
def place(col, row):
|
||||
px = (col + 0.5) * scale - half_w
|
||||
py = (row + 0.5) * scale - half_h
|
||||
px += shear * py
|
||||
x = cx + px * cos_a - py * sin_a
|
||||
y = cy + px * sin_a + py * cos_a
|
||||
return x, y + amp * math.sin(x / period + phase)
|
||||
|
||||
for row, bits in enumerate(glyph):
|
||||
col = 0
|
||||
while col < len(bits):
|
||||
if bits[col] != "#":
|
||||
col += 1
|
||||
continue
|
||||
start = col
|
||||
while col + 1 < len(bits) and bits[col + 1] == "#":
|
||||
col += 1 # 连续的一段合起来画,笔画才连得上
|
||||
x0, y0 = place(start, row)
|
||||
x1, y1 = place(col, row)
|
||||
_brush_line(cv, x0, y0, x1, y1, color, r)
|
||||
col += 1
|
||||
|
||||
|
||||
def render(code, width=150, height=56, scale=5, rng=None):
|
||||
"""把验证码渲染成 PNG 字节串。
|
||||
|
||||
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
|
||||
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
|
||||
字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 ——
|
||||
目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同,
|
||||
从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。
|
||||
"""
|
||||
rng = rng or random.SystemRandom()
|
||||
n = len(code)
|
||||
gap = 7
|
||||
text_w = n * GLYPH_W * scale + (n - 1) * gap
|
||||
if text_w + 16 > width:
|
||||
width = text_w + 16
|
||||
x0 = max(4, (width - text_w) // 2)
|
||||
y0 = max(3, (height - GLYPH_H * scale) // 2)
|
||||
gap = 9
|
||||
# 宽度按最大可能字号算,且左右各留够旋转半径 ——
|
||||
# 旋转后的字符会往两侧探出约半个字高,留窄了最外侧那个字会被裁掉一截,
|
||||
# 而「被裁掉一角的字符」会直接变成一次没道理的输错(体验问题,不是安全问题)。
|
||||
text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap
|
||||
need_w = text_w + int(GLYPH_H * (scale + 1) * 0.9) + 8
|
||||
if need_w > width:
|
||||
width = need_w
|
||||
# 高度同理:旋转后的字符比原始点阵高不少,切了顶就等于少一个笔画特征
|
||||
need_h = int(GLYPH_H * (scale + 1) * 1.7) + 8
|
||||
if need_h > height:
|
||||
height = need_h
|
||||
x0 = max(5, (width - text_w) // 2)
|
||||
y0 = height // 2
|
||||
|
||||
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
|
||||
# 而且打印/截图后对比度更差。
|
||||
bg = tuple(rng.randint(238, 252) for _ in range(3))
|
||||
cv = _Canvas(width, height, bg)
|
||||
# 背景不做纯色:纯色底可以用一个阈值把前景整片切出来。
|
||||
# 用「两色之间做斜向渐变」能让全局二值化的效果明显变差。
|
||||
c1 = tuple(rng.randint(236, 252) for _ in range(3))
|
||||
c2 = tuple(rng.randint(214, 240) for _ in range(3))
|
||||
slant = rng.uniform(-1.0, 1.0)
|
||||
cv = _Canvas(width, height, c1)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
t = (x / float(width - 1 or 1)) * 0.6 + (y / float(height - 1 or 1)) * 0.4
|
||||
t = min(1.0, max(0.0, t + slant * 0.15))
|
||||
cv.dot(x, y, tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)))
|
||||
|
||||
# 1) 干扰线(先画,压在字下面,不遮挡主体)
|
||||
for _ in range(4):
|
||||
# 1) 底层干扰线(先画,压在字下面)
|
||||
for _ in range(3):
|
||||
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
tuple(rng.randint(150, 205) for _ in range(3)))
|
||||
tuple(rng.randint(170, 215) for _ in range(3)))
|
||||
|
||||
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
|
||||
# 2) 字符本体
|
||||
step = GLYPH_W * (scale + 1) + gap
|
||||
for i, ch in enumerate(code):
|
||||
glyph = _FONT.get(ch)
|
||||
if glyph is None:
|
||||
continue
|
||||
color = tuple(rng.randint(20, 105) for _ in range(3))
|
||||
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
|
||||
gy = y0 + rng.randint(-2, 2)
|
||||
for row, bits in enumerate(glyph):
|
||||
for col, bit in enumerate(bits):
|
||||
if bit == "#":
|
||||
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
|
||||
# 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了
|
||||
s = max(3, scale + rng.choice((-1, 0, 0, 1)))
|
||||
cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0)
|
||||
cy = y0 + rng.uniform(-3.0, 3.0)
|
||||
color = tuple(rng.randint(15, 95) for _ in range(3))
|
||||
_draw_char(cv, glyph, cx, cy, s, color, rng)
|
||||
|
||||
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
|
||||
for _ in range(46):
|
||||
# 3) 前景噪点:破坏「按连通域找字符」的假设
|
||||
for _ in range(70):
|
||||
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
tuple(rng.randint(90, 190) for _ in range(3)))
|
||||
tuple(rng.randint(90, 195) for _ in range(3)))
|
||||
|
||||
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
|
||||
for _ in range(3):
|
||||
# 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出,
|
||||
# 所以刻意控制成 2~3 条细线。
|
||||
for _ in range(rng.randint(2, 3)):
|
||||
y = rng.randint(2, height - 3)
|
||||
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
|
||||
cv.line(0, y, width - 1, y + rng.randint(-11, 11),
|
||||
tuple(rng.randint(120, 175) for _ in range(3)))
|
||||
|
||||
return encode_png(width, height, cv.bytes())
|
||||
|
||||
@@ -205,6 +205,22 @@ def record_count(conn, uid):
|
||||
(uid or 0,)).fetchone()[0]
|
||||
|
||||
|
||||
def max_range_days(conn):
|
||||
"""单次采集允许的最长跨度(天)。
|
||||
|
||||
配置值是给管理员的旋钮,**代码层的硬顶**才是兜底:历史脏数据、直接改库、
|
||||
或者某次误配置都不该让一次请求变成几千次云端调用。
|
||||
接口校验、页面提示、sync() 里的实际收窄都读这一个函数,避免三处口径漂移。
|
||||
"""
|
||||
n = db.get_int(conn, "collect_max_range_days", config.COLLECT_MAX_RANGE_DAYS_HARD)
|
||||
return max(1, min(config.COLLECT_MAX_RANGE_DAYS_HARD, n))
|
||||
|
||||
|
||||
def min_interval_seconds(conn):
|
||||
"""同一账号两次手动采集之间的最小间隔(秒)。"""
|
||||
return max(0, min(3600, db.get_int(conn, "collect_min_interval_seconds", 60)))
|
||||
|
||||
|
||||
def last_ts(conn, uid):
|
||||
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
|
||||
(uid or 0,)).fetchone()[0]
|
||||
@@ -285,6 +301,18 @@ def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None
|
||||
end = to_dt or now
|
||||
if start >= end:
|
||||
start = end - timedelta(minutes=rewind)
|
||||
# 跨度上限(**代码层兜底**,不只是接口校验)。
|
||||
# 采集一次 = 对云端发 ceil(条数/page_size) 次请求,跨度越长请求越多。
|
||||
# 不设顶时,一个注册账号用 from=2000-01-01 就能让服务端替它打几千次云端,
|
||||
# 同时独占全局采集锁与一个 waitress 线程 —— 最省力的资源耗尽方式。
|
||||
# 这里对「显式跨度」和「断点很旧导致的实际跨度」一视同仁地收窄。
|
||||
max_days = max_range_days(conn)
|
||||
if end - start > timedelta(days=max_days):
|
||||
original = start
|
||||
start = end - timedelta(days=max_days)
|
||||
_log("[warn] 请求跨度超过上限 %d 天,已自动收窄起点:%s -> %s"
|
||||
% (max_days, original.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
start.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
end.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
|
||||
|
||||
+118
-10
@@ -28,6 +28,13 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
|
||||
APP_LOG = os.path.join(LOG_DIR, "app.log")
|
||||
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
|
||||
|
||||
# 备份落点。**刻意与 DATA_DIR 分开**:
|
||||
# * 容器里 DATA_DIR 挂的是数据卷,`docker compose down -v` 会连卷一起删;
|
||||
# 备份若躺在同一个卷里,就等于「正本与副本同时消失」—— 备份的意义没了。
|
||||
# * 备份里含 settings 的凭证密文与 users 的口令散列,必须能单独控制权限、
|
||||
# 单独挂卷、单独排除出镜像(见 .dockerignore 的 backups/)。
|
||||
BACKUP_DIR = os.environ.get("WB_BACKUP_DIR") or os.path.join(BASE_DIR, "backups")
|
||||
|
||||
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
|
||||
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
|
||||
# 后面两个保留以兼容「还没挪走」的部署。
|
||||
@@ -58,6 +65,13 @@ DEFAULTS = {
|
||||
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
|
||||
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
|
||||
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
|
||||
"max_schedule_slots_per_day": "6", # 每天最多几个时刻(挡住「填 200 个时刻」)
|
||||
"collect_min_interval_seconds": "60", # 同一账号两次手动采集的最小间隔
|
||||
"collect_max_range_days": "31", # 单次采集的最长跨度(硬顶 31 天 = 1 个月)
|
||||
# ---- 实例级:自动备份 ----
|
||||
"backup_enabled": "1", # 是否开启自动备份
|
||||
"backup_interval_hours": "24", # 备份周期(小时)
|
||||
"backup_keep": "7", # 保留最近几份,超出的自动删除最旧的
|
||||
# ---- 实例级:采集参数 ----
|
||||
"page_size": "200",
|
||||
"rewind_minutes": "2", # 断点回退分钟数
|
||||
@@ -91,6 +105,12 @@ GLOBAL_KEYS = {
|
||||
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
|
||||
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
|
||||
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
|
||||
"max_schedule_slots_per_day",
|
||||
# 任务频率与采集跨度(v1.4.0 起:对外提供服务时必须能限流,
|
||||
# 否则一个注册账号就能拿 /api/collect 把云端与线程池打满)
|
||||
"collect_min_interval_seconds", "collect_max_range_days",
|
||||
# 自动备份(谁掌握备份谁就掌握全库数据,所以归管理员)
|
||||
"backup_enabled", "backup_interval_hours", "backup_keep",
|
||||
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
|
||||
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
|
||||
"verify_days", "timeout", "ssl_verify",
|
||||
@@ -155,18 +175,33 @@ NUM_SETTINGS = {
|
||||
"catch_up_grace_hours": (1, 168, "小时"),
|
||||
"captcha_length": (4, 6, "个字符"),
|
||||
"register_max_per_ip": (1, 50, "个/天"),
|
||||
"max_schedule_slots_per_day": (1, 12, "个/天"),
|
||||
"collect_min_interval_seconds": (0, 3600, "秒"),
|
||||
"collect_max_range_days": (1, 31, "天"),
|
||||
"backup_interval_hours": (1, 720, "小时"),
|
||||
"backup_keep": (1, 100, "份"),
|
||||
}
|
||||
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"}
|
||||
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register", "backup_enabled"}
|
||||
|
||||
# 采集跨度的**硬顶**:无论 settings 里被改成什么(含历史脏数据、手工改库),
|
||||
# 代码层一律按这个上限夹一次。写进配置只是给管理员一个更严的旋钮,
|
||||
# 不是「改大就能突破」——上限必须由代码兜底,不能只靠校验。
|
||||
COLLECT_MAX_RANGE_DAYS_HARD = 31
|
||||
# 每日调度时刻的硬顶(同上)
|
||||
SCHEDULE_SLOTS_HARD_MAX = 12
|
||||
|
||||
_TRUE = ("1", "true", "yes", "on", "是", "启用")
|
||||
|
||||
|
||||
def normalize_setting(key, raw):
|
||||
def normalize_setting(key, raw, conn=None):
|
||||
"""校验并规范化单个设置值。
|
||||
|
||||
返回 (value, error):
|
||||
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
|
||||
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
|
||||
* `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受
|
||||
`max_schedule_slots_per_day` 约束),有连接时才查得到。
|
||||
不传时只做代码层的硬顶校验,所以离线调用不会因此失败。
|
||||
"""
|
||||
if key not in DEFAULTS:
|
||||
return None, "未知配置项:%s" % key
|
||||
@@ -196,6 +231,19 @@ def normalize_setting(key, raw):
|
||||
parsed = scheduler.parse_times(raw)
|
||||
if not parsed:
|
||||
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
|
||||
if len(parsed) > SCHEDULE_SLOTS_HARD_MAX:
|
||||
return None, "每日时刻最多 %d 个(当前填了 %d 个)" % (
|
||||
SCHEDULE_SLOTS_HARD_MAX, len(parsed))
|
||||
# 可配置的更严上限:时刻数量直接决定调度器的采集频次,
|
||||
# 是「一个账号能不能把云端与线程池打满」的开关,所以要有刹车。
|
||||
if conn is not None:
|
||||
from . import db as _db
|
||||
cap = _db.get_int(conn, "max_schedule_slots_per_day", 6)
|
||||
cap = max(1, min(SCHEDULE_SLOTS_HARD_MAX, cap))
|
||||
if len(parsed) > cap:
|
||||
return None, ("每日时刻最多 %d 个(当前填了 %d 个)。"
|
||||
"如需更多,请先把「每日调度时刻上限」调大。"
|
||||
% (cap, len(parsed)))
|
||||
return ",".join(parsed), None
|
||||
|
||||
if key == "captcha_policy":
|
||||
@@ -208,8 +256,14 @@ def normalize_setting(key, raw):
|
||||
v = str(raw).strip()
|
||||
if not v:
|
||||
return None, "%s 不能为空" % key
|
||||
if key == "api_base" and not v.startswith(("http://", "https://")):
|
||||
return None, "接口基址需以 http:// 或 https:// 开头"
|
||||
if key == "api_base":
|
||||
if not v.startswith(("http://", "https://")):
|
||||
return None, "接口基址需以 http:// 或 https:// 开头"
|
||||
# 云元数据地址永远不该是「云端接口」:它是 SSRF 拿云上临时凭证
|
||||
# 最经典的一跳,而且没有任何合法的采集场景需要它。
|
||||
host = v.split("//", 1)[1].split("/", 1)[0].split(":")[0].lower()
|
||||
if host in ("169.254.169.254", "metadata.google.internal", "[fd00:ec2::254]"):
|
||||
return None, "接口基址不能指向云元数据地址"
|
||||
return v, None
|
||||
|
||||
if key == "cookie":
|
||||
@@ -222,8 +276,57 @@ def normalize_setting(key, raw):
|
||||
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
|
||||
DEFAULT_PORT = 8848
|
||||
SESSION_HOURS = 12
|
||||
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数
|
||||
LOGIN_LOCK_MINUTES = 10
|
||||
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁)
|
||||
LOGIN_LOCK_MINUTES = 10 # 硬锁时长(仅 IP 维度)
|
||||
# 用户名维度的**软退避**:对外提供服务后,「知道一个用户名就能把它锁死 10 分钟」
|
||||
# 本身就是一种攻击(拿管理员用户名当武器,别人也用不了)。所以用户名维度
|
||||
# 只产生秒级、递增、有封顶的等待,真正的重锁只按来源 IP 施加。
|
||||
USER_SOFT_THRESHOLD = 5 # 同一用户名失败超过这个次数才开始退避
|
||||
USER_SOFT_CAP_SECONDS = 60 # 退避封顶
|
||||
# 同一来源的登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」的形态
|
||||
LOGIN_ATTEMPTS_PER_IP = 40
|
||||
LOGIN_ATTEMPTS_WINDOW = 300 # 秒
|
||||
|
||||
# ---------------- 反向代理与传输安全 ----------------
|
||||
def _env_flag(name, default="0"):
|
||||
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# 是否信任 X-Forwarded-For。**默认不信任**。
|
||||
# 直接暴露给公网(或前面只有一个「追加型」代理)时,XFF 的第 0 段是攻击者
|
||||
# 自己填的:一旦采信,验证码限速、注册配额、登录锁定三道 IP 防线会同时失效
|
||||
# (实测:每次换一个伪造 XFF,45 次验证码请求全部放行)。
|
||||
# 只有在**你自己的**反向代理会重写该头(nginx: `$remote_addr`)时才置 1。
|
||||
TRUST_PROXY = _env_flag("WB_TRUST_PROXY")
|
||||
# 强制跳转 HTTPS(配合反代时用;读到 X-Forwarded-Proto: https 就不跳)
|
||||
FORCE_HTTPS = _env_flag("WB_FORCE_HTTPS")
|
||||
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
|
||||
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
|
||||
COOKIE_SECURE = _env_flag("WB_COOKIE_SECURE")
|
||||
|
||||
# 访问日志:waitress 自己不记 access log,上线后没有访问日志等于出事无据可查。
|
||||
# 只记非静态资源请求,写进 logs/app.log(滚动 2MB × 3)。
|
||||
ACCESS_LOG = _env_flag("WB_ACCESS_LOG", "1")
|
||||
|
||||
# waitress 线程数。与容器 CPU 上限配套:线程越多,单实例能同时吃进的
|
||||
# 慢请求(如采集、导出)就越多 —— 对外提供服务时这是资源上限的一部分。
|
||||
THREADS = int(os.environ.get("WB_THREADS") or 8)
|
||||
|
||||
# 首个管理员的初始口令。**不给默认值**:留空时 db.init_db 会生成一个随机口令
|
||||
# 并只在启动日志里打印一次 —— 硬编码一个 admin123 等于把公网实例的钥匙挂在门上。
|
||||
ADMIN_USER = os.environ.get("WB_ADMIN_USER") or "admin"
|
||||
ADMIN_PASSWORD = os.environ.get("WB_ADMIN_PASSWORD") or ""
|
||||
|
||||
# 口令黑名单:这些是自动撞库字典的头几页,命中即拒。
|
||||
# 只在「设置/修改口令」时校验(登录不校验),所以不会把用老口令的人挡在门外。
|
||||
WEAK_PASSWORDS = {
|
||||
"12345678", "123456789", "1234567890", "password", "password1", "password123",
|
||||
"passw0rd", "qwertyui", "qwerty123", "abc12345", "abcd1234", "admin123",
|
||||
"admin888", "admin1234", "administrator", "root1234", "letmein1", "welcome1",
|
||||
"iloveyou", "monkey123", "dragon123", "sunshine", "princess", "football",
|
||||
"baseball", "11111111", "00000000", "88888888", "66666666", "asdasd123",
|
||||
"1qaz2wsx", "zxcvbnm1", "a1234567", "workbuddy", "codebuddy",
|
||||
}
|
||||
|
||||
# ---------------- 账号与口令策略 ----------------
|
||||
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
|
||||
@@ -233,13 +336,13 @@ PASSWORD_MAX = 128
|
||||
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
|
||||
PROFILE_EMAIL_MAX = 128
|
||||
|
||||
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
|
||||
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
|
||||
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on")
|
||||
# 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段,
|
||||
# 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定,
|
||||
# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
|
||||
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
@@ -265,6 +368,11 @@ def _instance_init(key, maker):
|
||||
try:
|
||||
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
# 这个文件里躺着 SECRET_KEY 与 cookie_key,权限等同管理员口令。
|
||||
# 默认 umask 022 会留下 0644(同机其他用户可读),所以在 POSIX 上
|
||||
# 显式收紧到 0600。Windows 没有这个概念,忽略即可。
|
||||
if os.name == "posix":
|
||||
os.chmod(INSTANCE_FILE, 0o600)
|
||||
except OSError:
|
||||
pass # 只读文件系统时退化为「本次进程内有效」
|
||||
return val
|
||||
|
||||
+76
-8
@@ -26,13 +26,17 @@
|
||||
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
|
||||
虽然时刻本身是实例级的 —— 这两个千万别一起改。
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from . import config, crypto
|
||||
|
||||
log = logging.getLogger("wb.db")
|
||||
|
||||
_local = threading.local()
|
||||
_init_lock = threading.Lock()
|
||||
_initialized = False
|
||||
@@ -41,11 +45,22 @@ _initialized = False
|
||||
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
|
||||
DB_SCHEMA_VERSION = 3
|
||||
# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
|
||||
DB_SCHEMA_VERSION = 4
|
||||
|
||||
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||
|
||||
# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里,
|
||||
# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。
|
||||
# `manage.py init` / entrypoint 负责把它打印到启动日志。
|
||||
_generated_admin_password = None
|
||||
|
||||
|
||||
def generated_admin_password():
|
||||
"""本次进程启动时生成的管理员口令(没有生成过则为 None)。"""
|
||||
return _generated_admin_password
|
||||
|
||||
|
||||
class SecretUnreadable(Exception):
|
||||
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
|
||||
@@ -167,7 +182,8 @@ def _migrate(conn):
|
||||
for col, ddl in (("email", "TEXT"),
|
||||
("status", "TEXT NOT NULL DEFAULT 'active'"),
|
||||
("register_ip", "TEXT"),
|
||||
("last_login_ip", "TEXT")):
|
||||
("last_login_ip", "TEXT"),
|
||||
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
|
||||
if col not in tusers:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
|
||||
done.append("users 增加 %s" % col)
|
||||
@@ -264,6 +280,38 @@ def _promote_personal_to_global(conn):
|
||||
return promoted, cleaned
|
||||
|
||||
|
||||
def _create_first_admin(conn, admin_user, admin_password, ts):
|
||||
"""建库后的第一个管理员。
|
||||
|
||||
口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。
|
||||
这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令
|
||||
等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。
|
||||
生成的口令只在启动日志里打印一次,并被提示立即修改。
|
||||
"""
|
||||
global _generated_admin_password
|
||||
from .security import hash_password
|
||||
pwd = (admin_password or config.ADMIN_PASSWORD or "").strip()
|
||||
generated = False
|
||||
if not pwd:
|
||||
pwd = secrets.token_urlsafe(12)
|
||||
generated = True
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
if generated:
|
||||
_generated_admin_password = pwd
|
||||
# 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。
|
||||
log.warning("=" * 68)
|
||||
log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user)
|
||||
log.warning(" 用户名:%s", admin_user)
|
||||
log.warning(" 口 令:%s", pwd)
|
||||
log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。")
|
||||
log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。")
|
||||
log.warning("=" * 68)
|
||||
return generated
|
||||
|
||||
|
||||
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
||||
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
|
||||
global _initialized
|
||||
@@ -316,12 +364,7 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
|
||||
if create_admin:
|
||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if n == 0:
|
||||
from .security import hash_password
|
||||
pwd = admin_password or "admin123"
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
_create_first_admin(conn, admin_user, admin_password, ts)
|
||||
_initialized = True
|
||||
return migrated
|
||||
finally:
|
||||
@@ -458,6 +501,31 @@ def user_count(conn):
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def session_ver_of(row):
|
||||
"""安全读取 session_ver。
|
||||
|
||||
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
|
||||
那会把「一次可恢复的登录失效」变成「整站 500」。
|
||||
"""
|
||||
try:
|
||||
if "session_ver" not in row.keys():
|
||||
return 0
|
||||
except AttributeError: # 不是 Row(dict 等)
|
||||
return 0
|
||||
return int(row["session_ver"] or 0)
|
||||
|
||||
|
||||
def bump_session_ver(conn, uid):
|
||||
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
|
||||
|
||||
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
|
||||
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
|
||||
旧会话依然有效到 12 小时之后。
|
||||
"""
|
||||
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
|
||||
(int(uid or 0),))
|
||||
|
||||
|
||||
# ---------------- 审计 ----------------
|
||||
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
|
||||
@@ -26,7 +26,7 @@ import os
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import collect, db
|
||||
from . import backup, collect, db
|
||||
|
||||
log = logging.getLogger("wb.scheduler")
|
||||
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
|
||||
@@ -176,6 +176,16 @@ class Scheduler:
|
||||
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
|
||||
except Exception as e:
|
||||
log.error("账号 %s 采集失败:%s", u["username"], e)
|
||||
|
||||
# ---- 自动备份(实例级,与具体账号无关,所以放在账号循环之外)----
|
||||
# 有采集在跑就跳过,等下一轮:备份会整库读一遍,没必要和采集抢磁盘。
|
||||
try:
|
||||
if os.path.exists(collect.LOCK_PATH):
|
||||
log.debug("有采集在跑,本次跳过自动备份")
|
||||
else:
|
||||
backup.maybe_auto(conn, now)
|
||||
except Exception as e: # 备份失败不能拖累调度本身
|
||||
log.exception("自动备份出错:%s", e)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
|
||||
session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效
|
||||
created_at TEXT,
|
||||
register_ip TEXT, -- 自助注册来源,用于每日限额
|
||||
last_login_at TEXT,
|
||||
@@ -119,3 +120,25 @@ CREATE TABLE IF NOT EXISTS captchas (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
|
||||
|
||||
-- ---------------- 备份索引 ----------------
|
||||
-- 表里只放「元数据」,备份文件本身在 config.BACKUP_DIR(**不在** data/ 卷里,
|
||||
-- 免得 `docker compose down -v` 把正本和备份一起删掉)。
|
||||
-- 刻意不记录备份内容、也不记录任何凭证 —— 这里只是一份可下载清单。
|
||||
CREATE TABLE IF NOT EXISTS backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL UNIQUE, -- 备份目录内的文件名(不含路径)
|
||||
bytes INTEGER NOT NULL DEFAULT 0, -- 归档大小
|
||||
sha256 TEXT NOT NULL DEFAULT '', -- 归档整体校验值(前 64 位十六进制)
|
||||
created_at TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual', -- manual | auto | cli | pre-restore
|
||||
actor TEXT, -- 触发者用户名(自动备份为 system)
|
||||
schema_ver INTEGER NOT NULL DEFAULT 0, -- 归档时的 user_version
|
||||
records INTEGER NOT NULL DEFAULT 0, -- 归档时的记录条数(便于挑一份恢复)
|
||||
credits REAL NOT NULL DEFAULT 0,
|
||||
users INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT, -- 备注 / 恢复来源
|
||||
missing INTEGER NOT NULL DEFAULT 0 -- 1 = 文件已不在磁盘上(手工删过)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_at ON backups(created_at DESC);
|
||||
|
||||
+216
-9
@@ -10,14 +10,26 @@
|
||||
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
|
||||
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
|
||||
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
|
||||
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
|
||||
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
|
||||
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
|
||||
users.status 与 users.session_ver,不靠会话过期来兜底。
|
||||
3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
|
||||
IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。
|
||||
|
||||
来源 IP 的取法(对外提供服务时最容易出错的一处)
|
||||
------------------------------------------------
|
||||
`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`:
|
||||
反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是
|
||||
攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道
|
||||
IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头)
|
||||
时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的
|
||||
一跳,由我们自己的代理写入,客户端伪造不了。
|
||||
"""
|
||||
import functools
|
||||
import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||
@@ -25,10 +37,50 @@ from flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||
|
||||
from . import captcha, config, db
|
||||
|
||||
log = logging.getLogger("wb.security")
|
||||
|
||||
# ---------------- 来源 IP(全站唯一入口) ----------------
|
||||
def _valid_ip(s):
|
||||
try:
|
||||
ipaddress.ip_address(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def client_ip():
|
||||
"""当前请求的客户端地址。
|
||||
|
||||
* `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。
|
||||
直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。
|
||||
* 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。
|
||||
最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。
|
||||
多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠
|
||||
代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。
|
||||
"""
|
||||
remote = (request.remote_addr or "").strip()
|
||||
if not config.TRUST_PROXY:
|
||||
return remote
|
||||
raw = request.headers.get("X-Forwarded-For", "")
|
||||
if not raw:
|
||||
return remote
|
||||
for part in reversed([p.strip() for p in raw.split(",")]):
|
||||
# 去掉 IPv6 的 [..]:port 写法
|
||||
cand = part.strip("[]").split("%")[0]
|
||||
if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit():
|
||||
cand = cand.rsplit(":", 1)[0] # IPv4:port
|
||||
if _valid_ip(cand):
|
||||
return cand
|
||||
# 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用
|
||||
log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120])
|
||||
return remote
|
||||
|
||||
|
||||
# ---------------- 失败计数(内存即可) ----------------
|
||||
# 单进程部署(见 README 的部署约束),重启清零可接受;
|
||||
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
|
||||
_fails = {} # key -> [count, last_ts]
|
||||
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
|
||||
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
|
||||
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
|
||||
|
||||
@@ -88,9 +140,76 @@ def fail_count(key):
|
||||
return c[0] if c else 0
|
||||
|
||||
|
||||
# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)----
|
||||
def ip_lock_left(ip):
|
||||
c = _fails.get(_ip_key(ip))
|
||||
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||
return 0
|
||||
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||
|
||||
|
||||
# ---- 用户名维度:只做秒级退避,**不做长锁** ----
|
||||
def user_soft_left(username):
|
||||
"""知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。
|
||||
|
||||
对外提供服务后,管理员用户名是公开信息(导航里就写着),
|
||||
如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员
|
||||
十分钟进不去。所以这里改成「递增且有封顶」的秒级等待:
|
||||
超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。
|
||||
真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、
|
||||
也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数,
|
||||
所以这种「软」不会让爆破变得可行。
|
||||
"""
|
||||
c = _fails.get(_user_key(username))
|
||||
if not c or c[0] < config.USER_SOFT_THRESHOLD:
|
||||
return 0
|
||||
delay = min(config.USER_SOFT_CAP_SECONDS,
|
||||
1 << min(10, c[0] - config.USER_SOFT_THRESHOLD))
|
||||
return max(0, int(delay - (time.time() - c[1])))
|
||||
|
||||
|
||||
# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」----
|
||||
def note_try(ip):
|
||||
now = time.time()
|
||||
if len(_tries) > _FAILS_MAX_KEYS:
|
||||
_tries.clear()
|
||||
cur = _tries.get(ip)
|
||||
if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW:
|
||||
_tries[ip] = [1, now]
|
||||
return 1
|
||||
cur[0] += 1
|
||||
return cur[0]
|
||||
|
||||
|
||||
def try_window_left(ip):
|
||||
cur = _tries.get(ip)
|
||||
if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP:
|
||||
return 0
|
||||
return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1])))
|
||||
|
||||
|
||||
def auth_locked(ip, username=""):
|
||||
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
|
||||
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
|
||||
"""还需等待的秒数(0 = 放行)。"""
|
||||
return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username))
|
||||
|
||||
|
||||
def auth_block_reason(ip, username=""):
|
||||
"""被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。"""
|
||||
if ip_lock_left(ip):
|
||||
return "ip"
|
||||
if try_window_left(ip):
|
||||
return "rate"
|
||||
if user_soft_left(username):
|
||||
return "user"
|
||||
return ""
|
||||
|
||||
|
||||
def auth_block_message(reason, seconds):
|
||||
if reason == "ip":
|
||||
return "该来源登录失败次数过多,请 %d 秒后再试" % seconds
|
||||
if reason == "rate":
|
||||
return "登录请求过于频繁,请 %d 秒后再试" % seconds
|
||||
return "尝试过于频繁,请 %d 秒后再试" % seconds
|
||||
|
||||
|
||||
def note_auth_fail(ip, username=""):
|
||||
@@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""):
|
||||
|
||||
|
||||
def clear_auth_fail(ip, username=""):
|
||||
"""登录成功后清掉两个维度的计数。
|
||||
|
||||
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
|
||||
之后,主人仍会被自己之前那几次的退避拖住。
|
||||
"""
|
||||
clear_fail(_ip_key(ip))
|
||||
if username:
|
||||
clear_fail(_user_key(username))
|
||||
|
||||
|
||||
# ---------------- 通用动作限速(重操作保护) ----------------
|
||||
# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求:
|
||||
# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程;
|
||||
# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车,
|
||||
# 否则**一个注册账号**就能把实例的线程与带宽吃干净。
|
||||
_actions = {} # "name:uid" -> next_allowed_ts
|
||||
|
||||
|
||||
def action_allowed(key, min_interval):
|
||||
"""返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。"""
|
||||
now = time.time()
|
||||
if len(_actions) > _FAILS_MAX_KEYS:
|
||||
_actions.clear()
|
||||
nxt = _actions.get(key) or 0
|
||||
if now < nxt:
|
||||
return False, int(nxt - now) + 1
|
||||
_actions[key] = now + max(0, int(min_interval))
|
||||
return True, 0
|
||||
|
||||
|
||||
def action_wait_left(key):
|
||||
return max(0, int((_actions.get(key) or 0) - time.time()))
|
||||
|
||||
|
||||
# ---------------- 口令 / 用户名策略 ----------------
|
||||
_USERNAME_RE = re.compile(config.USERNAME_RE)
|
||||
|
||||
@@ -149,6 +297,10 @@ def password_problem(new, new2=None, username=None):
|
||||
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
|
||||
if classes < 2:
|
||||
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
|
||||
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
|
||||
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
|
||||
if new.lower() in config.WEAK_PASSWORDS:
|
||||
return "这个密码在常见弱口令字典里,请换一个"
|
||||
if new2 is not None and new2 != new:
|
||||
return "两次输入的新密码不一致"
|
||||
if username and new.lower() == str(username).lower():
|
||||
@@ -182,8 +334,12 @@ def login_ok(conn, username, password):
|
||||
def current_user():
|
||||
"""当前登录用户(dict)或 None。
|
||||
|
||||
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
|
||||
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
|
||||
每个请求回查一次 users 表,两道校验:
|
||||
* `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
|
||||
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
|
||||
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
|
||||
旧会话照样有效到 12 小时之后。
|
||||
结果缓存在 flask.g 里,一次请求只查一次。
|
||||
"""
|
||||
if "wb_user" in g:
|
||||
return g.wb_user
|
||||
@@ -192,12 +348,24 @@ def current_user():
|
||||
if uid:
|
||||
try:
|
||||
row = db.get_db().execute(
|
||||
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
|
||||
(uid,)).fetchone()
|
||||
"SELECT id,username,display_name,is_admin,status,session_ver"
|
||||
" FROM users WHERE id=?", (uid,)).fetchone()
|
||||
except sqlite3.OperationalError as e:
|
||||
# users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db
|
||||
# 就先把 Web 起起来了,老库还没有 session_ver 这一列)。
|
||||
# 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉,
|
||||
# 症状会变成「全站所有人被踢下线、日志里什么都没有」——
|
||||
# 界面上只看到「登录成功又立刻跳回登录页」,极难归因。
|
||||
log.error("会话校验失败:users 表结构与代码不一致(%s);"
|
||||
"请执行 `python manage.py init` 完成迁移", e)
|
||||
row = None
|
||||
except Exception: # noqa: BLE001 (无请求上下文等)
|
||||
row = None
|
||||
if row is None or (row["status"] or "active") != "active":
|
||||
session.clear()
|
||||
elif session.get("sv", 0) != db.session_ver_of(row):
|
||||
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
|
||||
session.clear()
|
||||
else:
|
||||
user = {"id": row["id"], "username": row["username"],
|
||||
"display_name": row["display_name"] or row["username"],
|
||||
@@ -219,12 +387,16 @@ def login_session(user):
|
||||
|
||||
`session.clear()` 是必须的:既清掉前一次的残留,
|
||||
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
|
||||
|
||||
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
|
||||
这一版会话会在下一个请求就被判为过期。
|
||||
"""
|
||||
session.clear()
|
||||
session["uid"] = user["id"]
|
||||
session["uname"] = user["username"]
|
||||
session["dname"] = user["display_name"] or user["username"]
|
||||
session["adm"] = 1 if user["is_admin"] else 0
|
||||
session["sv"] = db.session_ver_of(user)
|
||||
session["login_at"] = db.now_str()
|
||||
session.permanent = True
|
||||
|
||||
@@ -401,19 +573,54 @@ def apply_security_headers(resp):
|
||||
resp.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
resp.headers.setdefault("Content-Security-Policy", CSP)
|
||||
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
|
||||
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
|
||||
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
|
||||
if config.COOKIE_SECURE or config.FORCE_HTTPS:
|
||||
resp.headers.setdefault("Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains")
|
||||
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
|
||||
resp.headers.setdefault("Cache-Control", "no-store")
|
||||
return resp
|
||||
|
||||
|
||||
def needs_https_redirect():
|
||||
"""当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。"""
|
||||
if not config.FORCE_HTTPS or request.is_secure:
|
||||
return False
|
||||
# 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。
|
||||
# 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。
|
||||
if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
|
||||
return False
|
||||
if request.method not in ("GET", "HEAD"):
|
||||
return False # 不重定向 POST:会丢请求体,行为难以预期
|
||||
return True
|
||||
|
||||
|
||||
def _access_log(resp, started):
|
||||
if not config.ACCESS_LOG:
|
||||
return resp
|
||||
path = request.path
|
||||
if path.startswith("/static/") or path == "/captcha.png":
|
||||
return resp # 静态资源与验证码出图会把日志刷满
|
||||
log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code,
|
||||
int((time.time() - started) * 1000), client_ip())
|
||||
return resp
|
||||
|
||||
|
||||
def init_app(app):
|
||||
app.jinja_env.globals["csrf_token"] = csrf_token
|
||||
app.jinja_env.globals["current_user"] = current_user
|
||||
|
||||
@app.before_request
|
||||
def _guard():
|
||||
g.wb_t0 = time.time()
|
||||
if needs_https_redirect():
|
||||
url = request.url.replace("http://", "https://", 1)
|
||||
return redirect(url, code=301)
|
||||
return check_csrf()
|
||||
|
||||
@app.after_request
|
||||
def _headers(resp):
|
||||
_access_log(resp, getattr(g, "wb_t0", time.time()))
|
||||
return apply_security_headers(resp)
|
||||
|
||||
+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"]})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}备份管理 · {{ project_title }}{% endblock %}
|
||||
{% block body %}
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>备份管理</h1>
|
||||
<p class="lead">
|
||||
整库<b>一致性快照</b>:可自动按周期备份、按份数自动清理,也能手工下载与恢复到任意一份
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="tag accent">仅管理员可见</span>
|
||||
<button class="btn primary" id="btnBackupNow" type="button">立即备份</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collectMsg" class="flash" style="display:none"></div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi" style="--c:var(--cyan)">
|
||||
<span>归档份数</span><b>{{ info.count }}</b>
|
||||
<i>保留上限 {{ info.keep }} 份</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--violet)">
|
||||
<span>备份占用</span><b>{{ info.total }}</b>
|
||||
<i>当前库 {{ info.db_bytes }}</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:{{ 'var(--green)' if info.enabled else 'var(--red)' }}">
|
||||
<span>自动备份</span><b>{{ '已开启' if info.enabled else '已关闭' }}</b>
|
||||
<i>每 {{ info.interval }} 小时一次</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--blue)">
|
||||
<span>上次 / 下次自动备份</span>
|
||||
<b>{{ info.last_auto[5:16] if info.last_auto != '—' else '还没有' }}</b>
|
||||
<i>{% if info.never_auto %}等待调度线程首轮检查(约 20 秒)
|
||||
{%- elif info.next_auto != '—' %}下次 {{ info.next_auto[5:16] }}
|
||||
{%- else %}未开启{% endif %}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flash warn" style="margin-bottom:16px">
|
||||
<b>恢复会覆盖当前全部数据</b>(账号、用量、配置一起换成归档里的那一份)。
|
||||
系统会在恢复前<b>自动先备份一次当前库</b>并保留在列表里,恢复错了可以再恢复到那一份。
|
||||
恢复完成后所有既有登录会话失效,需要重新登录。
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>自动备份设置</h2>
|
||||
<span class="tag accent">实例级</span>
|
||||
</div>
|
||||
<form id="formBackup">
|
||||
{% set b = num_settings %}
|
||||
<label class="row"><span>启用自动备份</span>
|
||||
<select name="backup_enabled">
|
||||
<option value="1" {{ 'selected' if s.backup_enabled != '0' }}>启用</option>
|
||||
<option value="0" {{ 'selected' if s.backup_enabled == '0' }}>关闭</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>备份周期</span>
|
||||
<input name="backup_interval_hours" type="number"
|
||||
min="{{ b.backup_interval_hours[0] }}" max="{{ b.backup_interval_hours[1] }}"
|
||||
value="{{ s.backup_interval_hours }}">
|
||||
<em class="unit">{{ b.backup_interval_hours[0] }}~{{ b.backup_interval_hours[1] }} 小时</em></label>
|
||||
<label class="row"><span>保留份数</span>
|
||||
<input name="backup_keep" type="number"
|
||||
min="{{ b.backup_keep[0] }}" max="{{ b.backup_keep[1] }}"
|
||||
value="{{ s.backup_keep }}">
|
||||
<em class="unit">份(超出的自动删最旧的)</em></label>
|
||||
<button class="btn primary" type="submit">保存备份设置</button>
|
||||
<p class="hint">
|
||||
自动备份在调度线程里执行,有采集任务在跑时会自动让路、下一轮再打。
|
||||
备份使用 SQLite 的在线备份 API,<b>采集写入期间也能拿到一致快照</b> ——
|
||||
这一点是手工 <code>cp usage.sqlite</code> 做不到的。
|
||||
每份归档打完后按「保留份数」清理最旧的那些。
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>归档里有什么</h2>
|
||||
<span class="tag mute">安全提示</span>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr><th>备份目录</th><td class="mono" style="word-break:break-all">{{ info.dir }}</td></tr>
|
||||
<tr><th>内容</th><td>
|
||||
所有 <code>*.sqlite</code> 的一致性快照<br>
|
||||
<code>instance.json</code>(含两把主密钥)<br>
|
||||
<code>manifest.json</code>(时间、行数、积分、逐文件校验值)
|
||||
</td></tr>
|
||||
<tr><th>为什么带上密钥</th><td>
|
||||
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档里的凭证密文
|
||||
就永远解不开 —— 那样的「恢复」等于把所有账号的 Cookie 弄丢。
|
||||
</td></tr>
|
||||
<tr><th>因此</th><td>
|
||||
<b>归档等于全库数据 + 密钥</b>,下载后请当作机密文件保管;
|
||||
它<b>不会</b>进入代码仓库、也不会被打进镜像(见 <code>.gitignore</code> /
|
||||
<code>.dockerignore</code>)。
|
||||
</td></tr>
|
||||
<tr><th>目录独立</th><td>
|
||||
备份目录刻意<b>不在 <code>data/</code> 里面</b>:容器里 <code>data/</code> 是数据卷,
|
||||
<code>docker compose down -v</code> 会把正本和副本一起删掉。
|
||||
容器里它挂在独立卷 <code>wb_backups</code> 上。
|
||||
</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>备份列表</h2>
|
||||
<span class="hint">新 → 旧,共 {{ rows|length }} 条(含已丢失的条目)</span>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table class="tbl" id="backupTable">
|
||||
<thead><tr>
|
||||
<th>文件名</th><th>生成时间</th><th>来源</th><th class="num">大小</th>
|
||||
<th class="num">记录</th><th class="num">积分</th><th class="num">账号</th>
|
||||
<th>库版本</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr data-file="{{ r.filename }}" data-exists="{{ 1 if r.exists else 0 }}">
|
||||
<td class="mono sm" style="word-break:break-all">
|
||||
{{ r.filename }}
|
||||
{% if not r.exists %}<br><span class="tag bad">文件已丢失</span>{% endif %}
|
||||
</td>
|
||||
<td class="mono sm nowrap">{{ r.created_at or '—' }}</td>
|
||||
<td class="nowrap">
|
||||
<span class="tag {{ 'accent' if r.trigger == 'manual' else ('warn' if r.trigger == 'pre-restore' else 'mute') }}">
|
||||
{{ r.trigger }}</span>
|
||||
{# CLI 造的备份 trigger 与 actor 都是 "cli",印两遍是纯噪音;
|
||||
只在两者不同时(auto/system、manual/张三)才补一行操作者 #}
|
||||
{% if r.actor and r.actor != r.trigger %}
|
||||
<br><span class="muted sm">{{ r.actor }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="num nowrap">{{ r.size_h }}</td>
|
||||
<td class="num">{% if r.records %}{{ '{:,}'.format(r.records) }}{% else %}—{% endif %}</td>
|
||||
<td class="num">{% if r.credits %}{{ '%.2f'|format(r.credits) }}{% else %}—{% endif %}</td>
|
||||
<td class="num">{% if r.users %}{{ r.users }}{% else %}—{% endif %}</td>
|
||||
<td class="mono sm">uv={{ r.schema_ver }}</td>
|
||||
<td class="nowrap">
|
||||
{% if r.exists %}
|
||||
<a class="btn sm ghost" href="{{ url_for('api.api_backup_download', filename=r.filename) }}">下载</a>
|
||||
<button class="btn sm" type="button" data-act="restore">恢复</button>
|
||||
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
||||
{% else %}
|
||||
<button class="btn sm ghost" type="button" data-act="forget"
|
||||
title="文件已不在磁盘上,只清掉这条索引记录">移除条目</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="empty">还没有任何备份。点右上角「立即备份」生成第一份。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">
|
||||
列表里的「记录 / 积分 / 账号」是归档生成时的计数,用来挑一份合适的恢复 ——
|
||||
恢复前程序还会再校验一次归档的完整性与库结构版本,校验不过不会动你的数据。
|
||||
档案文件名只能由本程序生成(服务端会拒绝任何带路径的文件名)。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script>
|
||||
WBU.bindForm('#formBackup', '/api/settings');
|
||||
|
||||
document.getElementById('btnBackupNow').addEventListener('click', function () {
|
||||
var btn = this, old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = '备份中…';
|
||||
WBU.post('/api/backups', {}).then(function (j) {
|
||||
if (j.ok === false) { WBU.say(j.message || '备份失败', 'error'); return; }
|
||||
WBU.say(j.message || '备份完成', 'ok');
|
||||
window.setTimeout(function () { location.reload(); }, 1200);
|
||||
}).catch(function (e) {
|
||||
if (String(e.message) !== 'unauthorized') WBU.say('请求失败:' + e.message, 'error');
|
||||
}).finally(function () { btn.disabled = false; btn.textContent = old; });
|
||||
});
|
||||
|
||||
document.getElementById('backupTable').addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
var row = btn.closest('tr'), file = row.dataset.file, act = btn.dataset.act;
|
||||
var old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
|
||||
function restore_(finish) {
|
||||
if (!window.confirm('确定从「' + file + '」恢复吗?\n\n'
|
||||
+ '当前所有账号、用量数据与配置都会被这份归档替换。\n'
|
||||
+ '系统会在替换前自动先备份当前库,恢复错了可以再恢复到那一份。')) {
|
||||
finish(); return;
|
||||
}
|
||||
var inc = window.confirm('是否同时恢复 instance.json(含 Cookie 加密主密钥)?\n\n'
|
||||
+ '点「确定」= 一并恢复(跨机器迁移必须选这个,否则已存的 Cookie 解密不出来)\n'
|
||||
+ '点「取消」= 只恢复数据库(保留本机现有密钥)');
|
||||
WBU.post('/api/backups/' + encodeURIComponent(file) + '/restore',
|
||||
{ include_instance: inc ? '1' : '0' })
|
||||
.then(function (j) {
|
||||
if (j.ok === false) { WBU.say(j.message || '恢复失败', 'error'); return; }
|
||||
WBU.say(j.message || '已恢复', 'ok');
|
||||
window.setTimeout(function () { location.href = '/'; }, 2500);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
|
||||
})
|
||||
.finally(finish);
|
||||
}
|
||||
|
||||
if (act === 'restore') {
|
||||
restore_(function () { btn.disabled = false; btn.textContent = old; });
|
||||
} else if (act === 'del') {
|
||||
if (!window.confirm('删除备份文件「' + file + '」?此操作不可撤销。')) {
|
||||
btn.disabled = false; btn.textContent = old; return;
|
||||
}
|
||||
WBU.post('/api/backups/' + encodeURIComponent(file) + '/delete', {})
|
||||
.then(function (j) {
|
||||
if (j.ok === false) { WBU.say(j.message || '删除失败', 'error'); return; }
|
||||
WBU.say(j.message || '已删除', 'ok');
|
||||
window.setTimeout(function () { location.reload(); }, 900);
|
||||
})
|
||||
.catch(function (err) { if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error'); })
|
||||
.finally(function () { btn.disabled = false; btn.textContent = old; });
|
||||
} else if (act === 'forget') {
|
||||
WBU.say('该文件已不在磁盘上,删除索引条目请用「清理旧备份」或手工删除数据库行。', 'warn');
|
||||
btn.disabled = false; btn.textContent = old;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,9 @@
|
||||
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
|
||||
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
|
||||
{% if cur.is_admin %}
|
||||
<a href="{{ url_for('views.backups_page') }}" class="{{ 'on' if nav=='backups' }}">备份管理</a>
|
||||
{% endif %}
|
||||
{% if cur.is_admin %}
|
||||
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
|
||||
{% endif %}
|
||||
{% if cur.is_admin %}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
|
||||
<a class="btn ghost" href="{{ url_for('views.profile_export') }}"
|
||||
title="导出你的全部用量明细、采集历史、操作审计与有效配置(不含 Cookie 明文)">导出我的全部数据</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,12 +59,32 @@
|
||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||
<button class="btn primary" type="submit">修改密码</button>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后当前会话仍有效,不必重新登录。</p>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
|
||||
且不能是常见弱口令。
|
||||
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
|
||||
「怀疑被盗所以改密码」才能真正生效。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>导出我的全部数据</h2>
|
||||
<span class="tag mute">数据可携带</span>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:0">
|
||||
打包下载<b>属于你账号</b>的所有内容:用量明细、采集历史、与你相关的操作审计、
|
||||
以及账号信息与对你有效的配置。
|
||||
<br><b>不含</b> Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、
|
||||
也不含其他任何账号的数据与实例级运行日志。
|
||||
</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
|
||||
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
|
||||
</div>
|
||||
<p class="hint">导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>我的采集凭证</h2>
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
|
||||
</label>
|
||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
|
||||
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p>
|
||||
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
|
||||
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
|
||||
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
|
||||
把「每日调度时刻上限」调大。</p>
|
||||
<label class="row"><span>启动补跑</span>
|
||||
<select name="catch_up">
|
||||
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
|
||||
@@ -75,11 +78,16 @@
|
||||
<hr class="sect-divider">
|
||||
<h3>历史回填</h3>
|
||||
<form id="formBackfill">
|
||||
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></label>
|
||||
<label class="row"><span>起始日期</span>
|
||||
<input type="date" name="from" max="{{ sch.now[:10] }}"
|
||||
data-min-days="{{ max_days }}"></label>
|
||||
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
|
||||
<button class="btn" type="submit">按区间补采</button>
|
||||
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
|
||||
区间越大耗时越长(云端按天分页拉取)。</p>
|
||||
<br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
|
||||
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
|
||||
这三条是为了避免把云端接口与本站线程池打满。
|
||||
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+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():
|
||||
|
||||
在新工单中引用
屏蔽一个用户