feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
+288
-73
@@ -5,12 +5,20 @@
|
||||
"""页面路由(Jinja 模板)。
|
||||
|
||||
分工:
|
||||
/ 概览(KPI + 入口)
|
||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
||||
/config 配置管理:Cookie / UA / 采集参数 / 改密码
|
||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
||||
/records 数据明细:分页、筛选、搜索、导出
|
||||
/ 概览(KPI + 入口)
|
||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||
/records 数据明细:分页、筛选、搜索、导出
|
||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
||||
/config 配置管理:本人的 Cookie / UA / 采集参数
|
||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
||||
/profile 个人中心:资料、密码、凭证状态
|
||||
/users 用户管理(仅管理员)
|
||||
/register 自助注册(受 allow_register 开关约束)
|
||||
/captcha.png 图形验证码
|
||||
|
||||
**多用户约定**
|
||||
所有数据类页面都只取 `current_user()["id"]` 那份数据;管理员在
|
||||
「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
@@ -18,12 +26,11 @@ import os
|
||||
import sqlite3
|
||||
|
||||
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
|
||||
request, send_from_directory, url_for)
|
||||
request, send_from_directory, session, url_for)
|
||||
|
||||
from .. import collect, config, db, query, scheduler
|
||||
from ..security import (admin_required, clear_fail, current_user, is_locked, lock_left,
|
||||
login_ok, login_required, login_session, logout_session,
|
||||
note_fail, safe_next)
|
||||
from .. import collect, config, db, query, scheduler, security
|
||||
from ..security import (admin_required, current_user, is_admin, login_required,
|
||||
safe_next)
|
||||
|
||||
bp = Blueprint("views", __name__)
|
||||
|
||||
@@ -32,42 +39,182 @@ def _ip():
|
||||
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
|
||||
|
||||
|
||||
def _uid():
|
||||
"""当前账号 id。调用方必须已过 @login_required。"""
|
||||
u = current_user()
|
||||
return u["id"] if u else 0
|
||||
|
||||
|
||||
def _shift(days):
|
||||
from datetime import datetime, timedelta
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ---------------- 验证码 ----------------
|
||||
@bp.get("/captcha.png")
|
||||
def captcha_png():
|
||||
"""生成一张验证码。答案只写进 captchas 表,会话里只记 id。"""
|
||||
purpose = (request.args.get("purpose") or "login").strip().lower()
|
||||
if purpose not in ("login", "register"):
|
||||
purpose = "login"
|
||||
if not security.captcha_fetch_allowed(_ip()):
|
||||
return "验证码请求过于频繁,请稍后再试", 429
|
||||
try:
|
||||
png = security.issue_captcha(db.get_db(), purpose)
|
||||
except sqlite3.Error:
|
||||
return "验证码服务暂不可用", 503
|
||||
resp = current_app.response_class(png, mimetype="image/png")
|
||||
# 必须禁缓存:否则浏览器复用旧图,而服务端那张已经被消费掉了,
|
||||
# 表现为「图没变但怎么输都错」。
|
||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------- 登录 ----------------
|
||||
def _login_ctx(**kw):
|
||||
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。"""
|
||||
def _login_ctx(conn=None, **kw):
|
||||
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。
|
||||
|
||||
`need_captcha` 与 `allow_register` 也在这里补齐 —— 登录页与注册页
|
||||
必须对「要不要验证码」保持一致,否则会出现「页面没给输入框、
|
||||
服务端却在校验」的死循环。
|
||||
"""
|
||||
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||
kw.setdefault("session_hours", config.SESSION_HOURS)
|
||||
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||
if conn is not None:
|
||||
kw.setdefault("need_captcha", security.captcha_required(conn, _ip(), kw.get("username") or ""))
|
||||
kw.setdefault("allow_register", security.register_allowed(conn))
|
||||
return kw
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
nxt = request.values.get("next") or ""
|
||||
conn = db.get_db()
|
||||
if request.method == "POST":
|
||||
ip = _ip()
|
||||
if is_locked(ip):
|
||||
n = lock_left(ip)
|
||||
flash("登录失败次数过多,请 %d 秒后再试" % n, "error")
|
||||
return render_template("login.html", **_login_ctx(next_url=nxt)), 429
|
||||
username = (request.form.get("username") or "").strip()
|
||||
pwd = request.form.get("password") or ""
|
||||
conn = db.get_db()
|
||||
user = login_ok(conn, username, pwd)
|
||||
|
||||
left = security.auth_locked(ip, username)
|
||||
if left:
|
||||
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip)
|
||||
flash("登录失败次数过多,请 %d 秒后再试" % left, "error")
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, next_url=nxt, username=username,
|
||||
need_captcha=True)), 429
|
||||
|
||||
# 验证码**先于**口令校验:否则攻击者可以拿「密码对不对」当信号,
|
||||
# 在解验证码之前就把字典跑完。
|
||||
need_cap = security.captcha_required(conn, ip, username)
|
||||
if need_cap and not security.consume_captcha(conn, "login", request.form.get("captcha")):
|
||||
n = security.note_auth_fail(ip, username)
|
||||
security.audit_login_fail(conn, username, "验证码错误(第 %d 次)" % n, ip)
|
||||
flash("验证码不正确或已过期,请重新输入", "error")
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, username=username, next_url=nxt,
|
||||
need_captcha=True)), 400
|
||||
|
||||
user, err = security.login_ok(conn, username, pwd)
|
||||
if user is None:
|
||||
n = note_fail(ip)
|
||||
db.audit(conn, "login_failed", username, "第 %d 次失败" % n, ip)
|
||||
flash("用户名或密码不正确(剩余尝试 %d 次)" % max(0, config.MAX_LOGIN_FAILS - n), "error")
|
||||
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")
|
||||
# 必须把 next 显式回填:失败后 request.args 为空,
|
||||
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
|
||||
return render_template("login.html", **_login_ctx(username=username, next_url=nxt)), 401
|
||||
clear_fail(ip)
|
||||
login_session(user)
|
||||
db.audit(conn, "login", username, "登录成功", ip)
|
||||
return redirect(safe_next(nxt, url_for("views.overview")))
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, username=username, next_url=nxt,
|
||||
need_captcha=True)), 401
|
||||
security.clear_auth_fail(ip, username)
|
||||
security.login_session(user)
|
||||
conn.execute("UPDATE users SET last_login_ip=? WHERE id=?", (ip, user["id"]))
|
||||
db.audit(conn, "login", username, "登录成功", ip, user["id"])
|
||||
target = safe_next(nxt, "")
|
||||
if not target:
|
||||
# 新注册 / 还没配凭证 → 直接带到配置页,少一步摸索
|
||||
cred = db.secret_state(conn, "cookie", user["id"])
|
||||
target = url_for("views.config_page") if not cred["set"] else url_for("views.overview")
|
||||
return redirect(target)
|
||||
|
||||
if current_user():
|
||||
return redirect(url_for("views.overview"))
|
||||
return render_template("login.html", **_login_ctx(next_url=nxt))
|
||||
return render_template("login.html", **_login_ctx(conn, next_url=nxt))
|
||||
|
||||
|
||||
# ---------------- 注册 ----------------
|
||||
def _register_ctx(**kw):
|
||||
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||
return kw
|
||||
|
||||
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
conn = db.get_db()
|
||||
if not security.register_allowed(conn):
|
||||
return render_template("error.html", code=403,
|
||||
message="管理员已关闭自助注册,请联系管理员开通账号"), 403
|
||||
if current_user():
|
||||
return redirect(url_for("views.overview"))
|
||||
|
||||
if request.method == "POST":
|
||||
ip = _ip()
|
||||
username = (request.form.get("username") or "").strip()
|
||||
display = (request.form.get("display_name") or "").strip()[:64]
|
||||
email = (request.form.get("email") or "").strip()[:128]
|
||||
pwd = request.form.get("password") or ""
|
||||
pwd2 = request.form.get("password2") or ""
|
||||
ctx = _register_ctx(username=username, display_name=display, email=email,
|
||||
need_captcha=True)
|
||||
|
||||
left = security.auth_locked(ip, username)
|
||||
if left:
|
||||
flash("操作过于频繁,请 %d 秒后再试" % left, "error")
|
||||
return render_template("register.html", **ctx), 429
|
||||
|
||||
# 注册一律要验证码:这是唯一能让陌生人写库的入口
|
||||
if not security.consume_captcha(conn, "register", request.form.get("captcha")):
|
||||
security.note_auth_fail(ip, username)
|
||||
db.audit(conn, "register_rejected", username or "-", "验证码错误", ip)
|
||||
flash("验证码不正确或已过期,请重新输入", "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
|
||||
ok, n, limit = security.register_quota(conn, ip)
|
||||
if not ok:
|
||||
db.audit(conn, "register_rejected", username or "-",
|
||||
"同 IP 当日注册数已达上限 %d" % limit, ip)
|
||||
flash("同一来源每天最多注册 %d 个账号,请明天再试或联系管理员" % limit, "error")
|
||||
return render_template("register.html", **ctx), 429
|
||||
|
||||
err = security.username_problem(username) or security.password_problem(pwd, pwd2, username)
|
||||
if err:
|
||||
security.note_auth_fail(ip, username)
|
||||
db.audit(conn, "register_rejected", username or "-", err, ip)
|
||||
flash(err, "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
if db.user_by_name(conn, username):
|
||||
# 用户名唯一性本来就暴露(注册时要查重),这里如实告知
|
||||
flash("用户名已被占用,请换一个", "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,"
|
||||
" created_at,register_ip) VALUES(?,?,?,?,0,'active',?,?)",
|
||||
(username, security.hash_password(pwd), display or username, email or None,
|
||||
db.now_str(), ip))
|
||||
uid = cur.lastrowid
|
||||
db.audit(conn, "register", username, "自助注册成功(账号 #%d)" % uid, ip, uid)
|
||||
# 注册即登录:少一次输密码,也顺手把会话建立起来
|
||||
row = db.user_by_id(conn, uid)
|
||||
security.login_session(row)
|
||||
security.clear_auth_fail(ip, username)
|
||||
flash("注册成功。请粘贴你自己账号的 Cookie —— 采集只使用本人的凭证。", "ok")
|
||||
return redirect(url_for("views.config_page"))
|
||||
|
||||
return render_template("register.html", **_register_ctx(need_captcha=True))
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@@ -76,8 +223,8 @@ def logout_post():
|
||||
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
|
||||
u = current_user()
|
||||
if u:
|
||||
db.audit(db.get_db(), "logout", u["username"], "", _ip())
|
||||
logout_session()
|
||||
db.audit(db.get_db(), "logout", u["username"], "", _ip(), u["id"])
|
||||
security.logout_session()
|
||||
flash("已退出登录", "ok")
|
||||
return redirect(url_for("views.login"))
|
||||
|
||||
@@ -96,29 +243,25 @@ def logout():
|
||||
@login_required
|
||||
def overview():
|
||||
conn = db.get_db()
|
||||
mf = query.manifest(conn)
|
||||
t = query.totals(conn)
|
||||
uid = _uid()
|
||||
mf = query.manifest(conn, uid)
|
||||
t = query.totals(conn, uid)
|
||||
today = db.now_str()[:10]
|
||||
st = query.summary(conn, today, today)
|
||||
d30 = query.summary(conn, _shift(-29), today)
|
||||
st = query.summary(conn, uid, today, today)
|
||||
d30 = query.summary(conn, uid, _shift(-29), today)
|
||||
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
|
||||
y = _shift(-1)
|
||||
yest = query.summary(conn, y, y)
|
||||
dims = query.dims(conn)
|
||||
yest = query.summary(conn, uid, y, y)
|
||||
dims = query.dims(conn, uid)
|
||||
# 注意:这里的 SQL 必须把模板用到的列都选出来(模板渲染 r.fetched,
|
||||
# 少选一列并不会报错,只会静默渲染成空白 —— 历史 bug)。
|
||||
runs = conn.execute(
|
||||
"SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,conflicts,message"
|
||||
" FROM collect_runs ORDER BY id DESC LIMIT 8").fetchall()
|
||||
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 8", (uid,)).fetchall()
|
||||
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
|
||||
yesterday=yest, yday=y,
|
||||
models=dims["model"][:8], clients=dims["client"],
|
||||
runs=runs, sch=_sch_info(conn), active="overview")
|
||||
|
||||
|
||||
def _shift(days):
|
||||
from datetime import datetime, timedelta
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
runs=runs, sch=_sch_info(conn, uid), active="overview")
|
||||
|
||||
|
||||
# ---------------- 大屏(独立 ECharts 页)----------------
|
||||
@@ -133,16 +276,17 @@ def dashboard():
|
||||
|
||||
|
||||
# ---------------- 任务管理 ----------------
|
||||
def _sch_info(conn):
|
||||
def _sch_info(conn, uid):
|
||||
sch = scheduler.get_scheduler()
|
||||
nxt = scheduler.next_run_at(conn)
|
||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
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()
|
||||
return {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
||||
"times": scheduler.slots(conn),
|
||||
"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),
|
||||
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||
"interval": sch.interval,
|
||||
"last": dict(last) if last else None,
|
||||
"lock": os.path.exists(collect.LOCK_PATH),
|
||||
@@ -154,14 +298,16 @@ def _sch_info(conn):
|
||||
@login_required
|
||||
def tasks():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||
size = 20
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
|
||||
runs = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(size, (page - 1) * size)).fetchall()
|
||||
s = db.get_settings(conn)
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||
(uid,)).fetchone()[0]
|
||||
runs = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC"
|
||||
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
pages = max(1, (total + size - 1) // size)
|
||||
return render_template("tasks.html", runs=runs, sch=_sch_info(conn),
|
||||
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",
|
||||
page=page, pages=pages, total=total,
|
||||
@@ -191,29 +337,60 @@ def _page_window(page, pages, span=9):
|
||||
@login_required
|
||||
def config_page():
|
||||
conn = db.get_db()
|
||||
s = db.get_settings(conn)
|
||||
uid = _uid()
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||
s.pop(k, None)
|
||||
cookie = (s.pop("cookie", "") or "")
|
||||
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (len(cookie), cookie[-16:])) if cookie else ""
|
||||
return render_template("config.html", s=s, sch=_sch_info(conn),
|
||||
# 加密键在 get_settings 里已经被置空,这里补上「状态摘要」给页面显示
|
||||
st = db.secret_state(conn, "cookie", uid)
|
||||
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||
s["cookie_broken"] = st["broken"]
|
||||
s["cookie_at"] = st["at"]
|
||||
return render_template("config.html", s=s, sch=_sch_info(conn, uid),
|
||||
secret_keys=config.SECRET_KEYS,
|
||||
num_settings=config.NUM_SETTINGS,
|
||||
pwd_min=config.PASSWORD_MIN,
|
||||
is_admin=is_admin(),
|
||||
global_keys=config.GLOBAL_KEYS,
|
||||
active="config")
|
||||
|
||||
|
||||
# ---------------- 个人中心 ----------------
|
||||
@bp.get("/profile")
|
||||
@login_required
|
||||
def profile_page():
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
row = db.user_by_id(conn, u["id"])
|
||||
cred = db.secret_state(conn, "cookie", u["id"])
|
||||
my = conn.execute(
|
||||
"SELECT COUNT(*) n, COALESCE(SUM(credits),0) c, MIN(day) d0, MAX(day) d1"
|
||||
" FROM usage_records WHERE user_id=?", (u["id"],)).fetchone()
|
||||
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||
(u["id"],)).fetchone()[0]
|
||||
return render_template("profile.html", me=dict(row), cred=cred, my=dict(my),
|
||||
runs=runs, sch=_sch_info(conn, u["id"]),
|
||||
pwd_min=config.PASSWORD_MIN, active="profile")
|
||||
|
||||
|
||||
# ---------------- 用户管理 ----------------
|
||||
@bp.get("/users")
|
||||
@admin_required
|
||||
def users_page():
|
||||
conn = db.get_db()
|
||||
users = conn.execute(
|
||||
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
|
||||
" FROM users ORDER BY id").fetchall()
|
||||
"SELECT u.id,u.username,u.display_name,u.email,u.is_admin,u.status,u.created_at,"
|
||||
" u.register_ip,u.last_login_at,u.last_login_ip,u.login_count,"
|
||||
" (SELECT COUNT(*) FROM usage_records r WHERE r.user_id=u.id) recs,"
|
||||
" (SELECT COALESCE(SUM(credits),0) FROM usage_records r WHERE r.user_id=u.id) credits"
|
||||
" FROM users u ORDER BY u.id").fetchall()
|
||||
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
|
||||
" OR action IN ('register','register_rejected','password','login_failed')"
|
||||
" ORDER BY id DESC LIMIT 20").fetchall()
|
||||
return render_template("users.html", users=users, audits=audits,
|
||||
me=current_user(), active="users")
|
||||
me=current_user(), allow_register=db.get_bool(
|
||||
conn, "allow_register", True),
|
||||
active="users")
|
||||
|
||||
|
||||
# ---------------- 日志管理 ----------------
|
||||
@@ -221,24 +398,50 @@ def users_page():
|
||||
@login_required
|
||||
def logs():
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
adm = bool(u["is_admin"])
|
||||
|
||||
run_id = request.args.get("run")
|
||||
detail = None
|
||||
if run_id and str(run_id).isdigit():
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?", (int(run_id),)).fetchone()
|
||||
# 明细也必须限本人:否则改一个 ?run= 就能看到别人的采集日志
|
||||
if adm:
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
|
||||
(int(run_id),)).fetchone()
|
||||
else:
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||||
(int(run_id), uid)).fetchone()
|
||||
|
||||
status = request.args.get("status") or ""
|
||||
w, p = ("WHERE status = ?", [status]) if status in ("ok", "warn", "error", "running") else ("", [])
|
||||
# 操作审计:按动作筛选 + 分页(原来只能看最近 40 条,等于不可查)
|
||||
w, p = ("WHERE user_id = ? AND status = ?", [uid, status]) \
|
||||
if status in ("ok", "warn", "error", "running") else ("WHERE user_id = ?", [uid])
|
||||
|
||||
# 操作审计:管理员看全部(便于追责),普通用户只看自己触发的
|
||||
aw, ap = [], []
|
||||
if not adm:
|
||||
aw.append("user_id = ?")
|
||||
ap.append(uid)
|
||||
act = request.args.get("act") or ""
|
||||
aw, ap = ("WHERE action = ?", [act]) if act else ("", [])
|
||||
if act:
|
||||
aw.append("action = ?")
|
||||
ap.append(act)
|
||||
aw_sql = ("WHERE " + " AND ".join(aw)) if aw else ""
|
||||
# 动作清单的统计基数不能带 action 条件(否则永远只剩一个动作可选)
|
||||
base = ("WHERE user_id = ?" if not adm else "")
|
||||
base_p = [uid] if not adm else []
|
||||
|
||||
apage = _int_arg("apage", 1, 1, 10 ** 6)
|
||||
asize = 20
|
||||
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw, ap).fetchone()[0]
|
||||
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw,
|
||||
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw_sql, ap).fetchone()[0]
|
||||
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw_sql,
|
||||
ap + [asize, (apage - 1) * asize]).fetchall()
|
||||
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
|
||||
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
|
||||
actions = conn.execute(
|
||||
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
|
||||
"SELECT action, COUNT(*) n FROM audit_log %s GROUP BY action ORDER BY n DESC, action"
|
||||
% base, base_p).fetchall()
|
||||
|
||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||
size = 30
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
||||
@@ -251,13 +454,21 @@ def logs():
|
||||
apage_window=_page_window(apage, apages, span=7),
|
||||
page=page, pages=max(1, (total + size - 1) // size), total=total,
|
||||
page_window=_page_window(page, max(1, (total + size - 1) // size)),
|
||||
status=status,
|
||||
status=status, audit_all=adm,
|
||||
active="logs")
|
||||
|
||||
|
||||
@bp.get("/logs/tail")
|
||||
@login_required
|
||||
def logs_tail():
|
||||
"""应用日志尾部(进程级,所有账号看到的是同一份)。
|
||||
|
||||
只对管理员开放:日志里会打印数据库路径、账号名等运行信息,
|
||||
没有理由让任意注册用户读到整个实例的运行轨迹。
|
||||
"""
|
||||
if not is_admin():
|
||||
return jsonify({"ok": False, "error": "forbidden",
|
||||
"message": "应用日志仅管理员可查看"}), 403
|
||||
n = _int_arg("lines", 200, 10, 2000)
|
||||
path = config.APP_LOG
|
||||
if not os.path.exists(path):
|
||||
@@ -289,6 +500,7 @@ def _day_args():
|
||||
@login_required
|
||||
def records():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
frm, to = _day_args()
|
||||
model = request.args.get("model") or None
|
||||
client = request.args.get("client") or None
|
||||
@@ -296,10 +508,10 @@ def records():
|
||||
order = request.args.get("order") or "ts_desc"
|
||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||
size = _int_arg("size", 50, 10, query.MAX_PAGE_SIZE)
|
||||
data = query.records_page(conn, frm, to, model=model, client=client, q=q,
|
||||
data = query.records_page(conn, uid, frm, to, model=model, client=client, q=q,
|
||||
page=page, size=size, order=order)
|
||||
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
|
||||
d = query.dims(conn)
|
||||
d = query.dims(conn, uid)
|
||||
models = [r["name"] for r in d["model"]]
|
||||
clients = [r["name"] for r in d["client"]]
|
||||
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
|
||||
@@ -321,6 +533,8 @@ def records_export():
|
||||
数据用 query.iter_records 流式取,不把整个结果集读进内存。
|
||||
"""
|
||||
from flask import Response
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
frm, to = _day_args()
|
||||
model = request.args.get("model") or None
|
||||
client = request.args.get("client") or None
|
||||
@@ -340,7 +554,7 @@ def records_export():
|
||||
# 自己开一条连接,并在流结束时关掉。
|
||||
own = db.connect()
|
||||
try:
|
||||
for r in query.iter_records(own, frm, to, model=model, client=client,
|
||||
for r in query.iter_records(own, uid, frm, to, model=model, client=client,
|
||||
q=q, order=order):
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
@@ -350,7 +564,8 @@ def records_export():
|
||||
finally:
|
||||
own.close()
|
||||
|
||||
name = "usage_%s_%s.csv" % (frm or "all", to or db.now_str()[:10])
|
||||
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
|
||||
name = "usage_%s_%s_%s.csv" % (u["username"], frm or "all", to or db.now_str()[:10])
|
||||
resp = Response(gen(), mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
|
||||
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
|
||||
|
||||
在新工单中引用
屏蔽一个用户