- 新增备份管理页与 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 配置
830 行
36 KiB
Python
830 行
36 KiB
Python
# -*- coding: utf-8 -*-
|
||
# SPDX-License-Identifier: MIT
|
||
# Copyright (c) 2026 Wang Chuanli
|
||
|
||
"""JSON API —— ECharts 大屏与后台页面的数据入口。
|
||
|
||
约定:
|
||
* 全部需要登录;POST 另需 CSRF(security.check_csrf 统一拦截)
|
||
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
||
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
||
/api/records/<request_id> 会带
|
||
|
||
**多用户约定**
|
||
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||
|
||
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
|
||
|
||
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
|
||
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
|
||
"""
|
||
import os
|
||
from datetime import datetime
|
||
|
||
from flask import Blueprint, jsonify, request, send_file, session
|
||
|
||
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")
|
||
|
||
|
||
def _uid():
|
||
u = current_user()
|
||
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
|
||
|
||
|
||
def _json_body():
|
||
body = request.get_json(silent=True)
|
||
return body if isinstance(body, dict) else {}
|
||
|
||
|
||
class BadParam(ValueError):
|
||
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
||
|
||
|
||
def _win(required=False):
|
||
"""解析 from/to,返回归一化后的 (from, to)。
|
||
|
||
归一化放在这里,视图与聚合层就不必再各自防御:
|
||
无法识别的写法('abc'、'2026-09-08 12:00:00' 里的空格等)此前会一路冒到
|
||
`datetime.strptime` 变成 HTTP 500,现在统一 400 并带上人话说明。
|
||
"""
|
||
raw_f, raw_t = _arg("from"), _arg("to")
|
||
f, t = query.norm_day(raw_f), query.norm_day(raw_t)
|
||
if raw_f and not f:
|
||
raise BadParam("参数 from 不是合法日期:%s(正确写法 2026-09-08)" % raw_f)
|
||
if raw_t and not t:
|
||
raise BadParam("参数 to 不是合法日期:%s(正确写法 2026-09-08)" % raw_t)
|
||
if f and t and f > t:
|
||
f, t = t, f
|
||
if required and (not f or not t):
|
||
raise BadParam("需要同时提供 from 与 to(YYYY-MM-DD)")
|
||
return f, t
|
||
|
||
|
||
def _int(name, default, lo=1, hi=2000):
|
||
try:
|
||
return max(lo, min(hi, int(request.args.get(name, default))))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
@bp.get("/manifest")
|
||
@login_required
|
||
def api_manifest():
|
||
u = current_user()
|
||
m = query.manifest(db.get_db(), u["id"])
|
||
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
|
||
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
|
||
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
|
||
m["role"] = "admin" if u["is_admin"] else "user"
|
||
return jsonify(m)
|
||
|
||
|
||
@bp.get("/bundle")
|
||
@login_required
|
||
def api_bundle():
|
||
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
|
||
frm, to = _win()
|
||
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
|
||
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
||
|
||
|
||
@bp.get("/summary")
|
||
@login_required
|
||
def api_summary():
|
||
conn = db.get_db()
|
||
uid = _uid()
|
||
frm, to = _win()
|
||
if not frm or not to:
|
||
t = query.totals(conn, uid)
|
||
frm, to = t["firstDay"], t["lastDay"]
|
||
return jsonify(query.summary(conn, uid, frm, to))
|
||
|
||
|
||
@bp.get("/daily")
|
||
@login_required
|
||
def api_daily():
|
||
return jsonify({"days": query.daily(db.get_db(), _uid(), *_win())})
|
||
|
||
|
||
@bp.get("/dims")
|
||
@login_required
|
||
def api_dims():
|
||
conn = db.get_db()
|
||
d = query.dims(conn, _uid(), *_win())
|
||
dim = _arg("dim")
|
||
if dim in d:
|
||
return jsonify({dim: d[dim]})
|
||
return jsonify(d)
|
||
|
||
|
||
@bp.get("/top")
|
||
@login_required
|
||
def api_top():
|
||
conn = db.get_db()
|
||
return jsonify(query.top(conn, _uid(), *_win(), n=_int("n", 50, 1, 1000)))
|
||
|
||
|
||
@bp.get("/records")
|
||
@login_required
|
||
def api_records():
|
||
conn = db.get_db()
|
||
uid = _uid()
|
||
frm, to = _win()
|
||
page = _int("page", 1, 1, 100000)
|
||
size = _int("size", 50, 1, 500)
|
||
r = query.records_page(conn, uid, frm, to, model=_arg("model"), client=_arg("client"),
|
||
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
||
with_prompt=False if _arg("lean") == "1" else True)
|
||
return jsonify(r)
|
||
|
||
|
||
@bp.get("/records/<request_id>")
|
||
@login_required
|
||
def api_record(request_id):
|
||
# user_id 必须进 WHERE:否则改一个 URL 就能读到别人的 Prompt 全文
|
||
row = db.get_db().execute("SELECT * FROM usage_records WHERE user_id=? AND request_id=?",
|
||
(_uid(), request_id)).fetchone()
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
||
return jsonify(dict(row))
|
||
|
||
|
||
@bp.get("/runs")
|
||
@login_required
|
||
def api_runs():
|
||
conn = db.get_db()
|
||
rows = conn.execute("SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
|
||
"win_to,fetched,added,dup,total,conflicts,exit_code,message"
|
||
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT ?",
|
||
(_uid(), _int("limit", 50, 1, 500)))
|
||
return jsonify({"items": [dict(r) for r in rows]})
|
||
|
||
|
||
@bp.get("/runs/<int:run_id>")
|
||
@login_required
|
||
def api_run(run_id):
|
||
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||
(run_id, _uid())).fetchone()
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
||
return jsonify(dict(row))
|
||
|
||
|
||
@bp.get("/status")
|
||
@login_required
|
||
def api_status():
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
uid = u["id"]
|
||
sch = scheduler.get_scheduler()
|
||
nxt = scheduler.next_run_at(conn, uid)
|
||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||
(uid,)).fetchone()
|
||
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),
|
||
"times": scheduler.slots(conn, uid),
|
||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||
"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,
|
||
# 只回「有没有配」与字符数,绝不回凭证内容
|
||
"cookie_set": bool(cred["set"] and not cred["broken"]),
|
||
"cookie_chars": cred["chars"],
|
||
"cookie_broken": cred["broken"],
|
||
})
|
||
|
||
|
||
@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:
|
||
d = query.norm_day(frm)
|
||
if not d:
|
||
raise BadParam("起始日期不合法:%s(正确写法 2026-09-08)" % frm)
|
||
kw["from_dt"] = datetime.strptime(d, "%Y-%m-%d")
|
||
if to:
|
||
d = query.norm_day(to)
|
||
if not d:
|
||
raise BadParam("结束日期不合法:%s(正确写法 2026-09-08)" % to)
|
||
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("起始日期不能晚于结束日期")
|
||
# ---- 闸门 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:
|
||
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
|
||
except collect.NotReady as e:
|
||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||
except db.SecretUnreadable as e:
|
||
return jsonify({"ok": False, "error": "cookie_broken",
|
||
"message": "已保存的 Cookie 无法解密(实例密钥被更换过):%s。"
|
||
"请到「配置管理」重新粘贴。" % e}), 409
|
||
except collect.ApiError as e:
|
||
code = 401 if e.cookie_expired else 502
|
||
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
|
||
"message": str(e)}), code
|
||
except Exception as e: # noqa: BLE001
|
||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
|
||
return jsonify({"ok": True, "result": r})
|
||
|
||
|
||
@bp.get("/audit")
|
||
@login_required
|
||
def api_audit():
|
||
"""操作审计分页。管理员看全部(便于追责),普通用户只看自己触发的。"""
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
action = _arg("action")
|
||
page = _int("page", 1, 1, 100000)
|
||
size = _int("size", 50, 1, 500)
|
||
w, p = [], []
|
||
if not u["is_admin"]:
|
||
w.append("user_id = ?")
|
||
p.append(u["id"])
|
||
if action:
|
||
w.append("action = ?")
|
||
p.append(action)
|
||
ws = ("WHERE " + " AND ".join(w)) if w else ""
|
||
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % ws, p).fetchone()[0]
|
||
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % ws,
|
||
p + [size, (page - 1) * size])
|
||
# 动作清单不带 action 条件,否则只剩下当前那一个动作可选
|
||
base_p = [u["id"]] if not u["is_admin"] else []
|
||
base = "WHERE user_id = ?" if not u["is_admin"] else ""
|
||
actions = [r[0] for r in conn.execute(
|
||
"SELECT DISTINCT action FROM audit_log %s ORDER BY action" % base, base_p)]
|
||
return jsonify({"total": total, "page": page, "size": size,
|
||
"pages": max(1, (total + size - 1) // size),
|
||
"scope": "all" if u["is_admin"] else "self",
|
||
"actions": actions,
|
||
"items": [dict(r) for r in rows]})
|
||
|
||
|
||
# ---------------- 维护动作(原来只有 CLI 能做) ----------------
|
||
@bp.post("/maintenance/<action>")
|
||
@login_required
|
||
def api_maintenance(action):
|
||
"""把 CLI 里的维护动作搬到页面上。
|
||
|
||
只有 `vacuum` 是**实例级**动作(整个库一起整理),所以它仅管理员可用;
|
||
其余三个都只作用于当前账号自己的数据。
|
||
"""
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
uid = u["id"]
|
||
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:
|
||
n = collect.fill_prompt(conn, uid, log=lambda m: None)
|
||
except collect.NotReady as e:
|
||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||
except db.SecretUnreadable as e:
|
||
return jsonify({"ok": False, "error": "cookie_broken",
|
||
"message": "已保存的 Cookie 无法解密,请重新粘贴:%s" % e}), 409
|
||
except collect.ApiError as e:
|
||
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
||
msg = "补全 %d 条 User Prompt" % n
|
||
elif action == "vacuum":
|
||
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||
conn.execute("VACUUM")
|
||
conn.execute("PRAGMA optimize")
|
||
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
|
||
elif action == "export-csv":
|
||
path, n = collect.export_csv(conn, uid, username=u["username"])
|
||
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
||
elif action == "recount":
|
||
n = collect.record_count(conn, uid)
|
||
msg = "存档当前 %d 条记录" % n
|
||
else:
|
||
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, _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":
|
||
return "%.1f %s" % (n, unit) if unit != "B" else "%d B" % n
|
||
n /= 1024.0
|
||
|
||
|
||
@bp.get("/settings")
|
||
@login_required
|
||
def api_settings_get():
|
||
"""当前账号的**有效配置**(不含任何凭证明文)。"""
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
s = db.get_settings(conn, uid=u["id"])
|
||
st = db.secret_state(conn, "cookie", u["id"])
|
||
s["cookie_hint"] = ("%d 字符,…%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||
s["cookie_broken"] = st["broken"]
|
||
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||
s.pop(k, None)
|
||
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)
|
||
|
||
|
||
@bp.post("/settings")
|
||
@login_required
|
||
def api_settings_post():
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
uid = u["id"]
|
||
body = _json_body()
|
||
changed, errors, ignored, denied = [], [], [], []
|
||
for k, v in body.items():
|
||
if config.is_internal_key(k):
|
||
ignored.append(k)
|
||
continue # slot:* 是调度簿记,不允许前台写
|
||
if k not in config.DEFAULTS:
|
||
errors.append("未知配置项:%s" % k)
|
||
continue
|
||
if not config.writable_by(k, u["is_admin"]):
|
||
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
|
||
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
|
||
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
|
||
# 或者把 page_size 调到 1000 去 hammer 云端接口。
|
||
denied.append(k)
|
||
continue
|
||
if k == "cookie":
|
||
raw = str(v).strip()
|
||
if not raw:
|
||
continue # 空值不动,避免误清
|
||
if raw.lower() in ("__clear__", "-"):
|
||
db.set_secret(conn, "cookie", "", uid)
|
||
changed.append(k)
|
||
continue
|
||
val, err = config.normalize_setting(k, v, conn)
|
||
if err:
|
||
errors.append(err)
|
||
continue
|
||
if k in config.ENCRYPTED_KEYS:
|
||
db.set_secret(conn, k, val, uid)
|
||
else:
|
||
db.set_setting(conn, k, val, uid)
|
||
changed.append(k)
|
||
if denied:
|
||
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
|
||
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
|
||
% "、".join(sorted(denied)))
|
||
if errors:
|
||
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
|
||
_ip(), uid)
|
||
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
|
||
"errors": errors, "changed": sorted(changed)}), 400
|
||
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
|
||
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
|
||
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
|
||
if "schedule_times" in changed:
|
||
keep = set(scheduler.slots(conn, 0))
|
||
stale = [(r["user_id"], r["key"]) for r in conn.execute(
|
||
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
||
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 "(无变化)"), _ip(), uid)
|
||
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
||
|
||
|
||
@bp.post("/password")
|
||
@login_required
|
||
def api_password():
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
body = _json_body()
|
||
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
||
if row is None or not security.verify_password(row["password_hash"], body.get("old") or ""):
|
||
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
||
new = (body.get("new") or "").strip()
|
||
err = security.password_problem(new, body.get("new2"), u["username"])
|
||
if err:
|
||
return jsonify({"ok": False, "message": err}), 400
|
||
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||
(security.hash_password(new), u["id"]))
|
||
# 改密即作废**其他**设备的会话:改密码的动机往往就是怀疑它泄漏了,
|
||
# 只改散列却留着旧会话,等于给自己一个假的安心。
|
||
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")
|
||
@login_required
|
||
def api_profile():
|
||
"""自助修改个人资料(显示名 / 邮箱)。用户名不可改 —— 它是审计里的主键。"""
|
||
conn = db.get_db()
|
||
u = current_user()
|
||
body = _json_body()
|
||
changed = []
|
||
if "display_name" in body:
|
||
name = (body.get("display_name") or "").strip()[:64] or u["username"]
|
||
conn.execute("UPDATE users SET display_name=? WHERE id=?", (name, u["id"]))
|
||
changed.append("显示名")
|
||
if "email" in body:
|
||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||
if email and ("@" not in email or " " in email):
|
||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, u["id"]))
|
||
changed.append("邮箱")
|
||
if not changed:
|
||
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
|
||
_ip(), u["id"])
|
||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||
|
||
|
||
# ---------------- 用户管理(管理员) ----------------
|
||
def _user_public(r):
|
||
"""用户行 -> 可下发结构。**绝不包含口令散列,也不包含任何凭证。**"""
|
||
return {"id": r["id"], "username": r["username"], "display_name": r["display_name"],
|
||
"email": r["email"], "is_admin": bool(r["is_admin"]),
|
||
"status": r["status"] or "active", "created_at": r["created_at"],
|
||
"register_ip": r["register_ip"], "last_login_at": r["last_login_at"],
|
||
"last_login_ip": r["last_login_ip"], "login_count": r["login_count"]}
|
||
|
||
|
||
@bp.get("/users")
|
||
@admin_required
|
||
def api_users():
|
||
rows = db.get_db().execute("SELECT * FROM users ORDER BY id").fetchall()
|
||
return jsonify({"items": [_user_public(r) for r in rows]})
|
||
|
||
|
||
@bp.post("/users")
|
||
@admin_required
|
||
def api_user_create():
|
||
conn = db.get_db()
|
||
me = current_user()
|
||
body = _json_body()
|
||
name = (body.get("username") or "").strip()
|
||
pwd = (body.get("password") or "").strip()
|
||
err = security.username_problem(name) or security.password_problem(
|
||
pwd, body.get("password2"), name)
|
||
if err:
|
||
return jsonify({"ok": False, "message": err}), 400
|
||
if db.user_by_name(conn, name):
|
||
return jsonify({"ok": False, "message": "用户名已存在"}), 400
|
||
# 默认建**普通账号**:多用户系统里「默认给管理员」是最常见的越权起点
|
||
adm = 1 if str(body.get("is_admin", "0")) in ("1", "true", "on") else 0
|
||
cur = conn.execute(
|
||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,created_at)"
|
||
" VALUES(?,?,?,?,?, 'active', ?)",
|
||
(name, security.hash_password(pwd),
|
||
(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 "普通"), _ip(), me["id"])
|
||
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
|
||
|
||
|
||
@bp.post("/users/<int:uid>")
|
||
@admin_required
|
||
def api_user_update(uid):
|
||
conn = db.get_db()
|
||
me = current_user()
|
||
row = db.user_by_id(conn, uid)
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||
body = _json_body()
|
||
changed = []
|
||
if "display_name" in body:
|
||
conn.execute("UPDATE users SET display_name=? WHERE id=?",
|
||
((body.get("display_name") or "").strip()[:64], uid))
|
||
changed.append("显示名")
|
||
if "email" in body:
|
||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||
if email and ("@" not in email or " " in email):
|
||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, uid))
|
||
changed.append("邮箱")
|
||
if "is_admin" in body:
|
||
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
|
||
if uid == me["id"] and not v:
|
||
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
|
||
if not v and row["is_admin"]:
|
||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||
" AND id<>?", (uid,)).fetchone()[0]
|
||
if left == 0:
|
||
return jsonify({"ok": False,
|
||
"message": "至少要保留一个启用状态的管理员"}), 400
|
||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
||
changed.append("管理员")
|
||
if "status" in body:
|
||
v = "active" if str(body.get("status")) in ("active", "1", "true", "on") else "disabled"
|
||
if uid == me["id"] and v != "active":
|
||
return jsonify({"ok": False, "message": "不能停用自己的账号"}), 400
|
||
if v != "active":
|
||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||
" AND id<>?", (uid,)).fetchone()[0]
|
||
if row["is_admin"] and left == 0:
|
||
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:
|
||
err = security.password_problem(pwd, body.get("password2"), row["username"])
|
||
if err:
|
||
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)),
|
||
_ip(), me["id"])
|
||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||
|
||
|
||
@bp.post("/users/<int:uid>/delete")
|
||
@admin_required
|
||
def api_user_delete(uid):
|
||
conn = db.get_db()
|
||
me = current_user()
|
||
row = db.user_by_id(conn, uid)
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||
if uid == me["id"]:
|
||
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
|
||
if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] <= 1:
|
||
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
||
if row["is_admin"]:
|
||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||
" AND id<>?", (uid,)).fetchone()[0]
|
||
if left == 0:
|
||
return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400
|
||
keep = str(_json_body().get("keep_data", "")).strip() in ("1", "true", "on", "yes")
|
||
if not keep:
|
||
# 默认连同数据一起删 —— 留下孤儿数据既占空间,也会在重新注册
|
||
# 同名用户时被新用户看到(历史遗留 user_id 复用风险)
|
||
conn.execute("DELETE FROM usage_records WHERE user_id=?", (uid,))
|
||
conn.execute("DELETE FROM settings WHERE user_id=?", (uid,))
|
||
conn.execute("DELETE FROM collect_runs WHERE user_id=?", (uid,))
|
||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||
db.audit(conn, "user_delete", me["username"],
|
||
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
|
||
_ip(), me["id"])
|
||
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|
||
|
||
|
||
@bp.post("/captcha")
|
||
@login_required
|
||
def api_captcha_note():
|
||
"""给前端一个「验证码怎么工作」的自述,便于排障时自检。"""
|
||
conn = db.get_db()
|
||
return jsonify({
|
||
"policy": db.get_setting(conn, "captcha_policy", "always"),
|
||
"length": db.get_int(conn, "captcha_length", 4),
|
||
"ttl_seconds": 300,
|
||
"image_url": "/captcha.png",
|
||
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。",
|
||
})
|