- 新增备份管理页与 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 配置
786 行
36 KiB
Python
786 行
36 KiB
Python
# -*- coding: utf-8 -*-
|
||
# SPDX-License-Identifier: MIT
|
||
# Copyright (c) 2026 Wang Chuanli
|
||
|
||
"""页面路由(Jinja 模板)。
|
||
|
||
分工:
|
||
/ 概览(KPI + 入口)
|
||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||
/records 数据明细:分页、筛选、搜索、导出
|
||
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
|
||
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
|
||
/logs 日志管理(**仅管理员**)
|
||
/profile 个人中心:资料、密码、凭证状态
|
||
/users 用户管理(仅管理员)
|
||
/register 自助注册(受 allow_register 开关约束)
|
||
/captcha.png 图形验证码
|
||
|
||
**多用户约定(两条)**
|
||
|
||
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
|
||
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||
|
||
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
|
||
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
|
||
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
|
||
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
|
||
"""
|
||
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 backup, collect, config, db, query, scheduler, security
|
||
from ..security import (admin_required, current_user, is_admin, login_required,
|
||
safe_next)
|
||
|
||
bp = Blueprint("views", __name__)
|
||
|
||
|
||
def _ip():
|
||
"""客户端地址。**全站统一走 security.client_ip()**。
|
||
|
||
原来这里直接取 `X-Forwarded-For` 的第 0 段,等于把「来源 IP」交给请求方
|
||
自己申报:验证码出图限速、注册配额、登录锁定三道 IP 防线会一起失效。
|
||
具体取法与开关见 security.client_ip 的注释。
|
||
"""
|
||
return security.client_ip()
|
||
|
||
|
||
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(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()
|
||
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:
|
||
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
|
||
|
||
# 验证码**先于**口令校验:否则攻击者可以拿「密码对不对」当信号,
|
||
# 在解验证码之前就把字典跑完。
|
||
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 = security.note_auth_fail(ip, username)
|
||
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
|
||
# 不再报「剩余 N 次」:用户名维度的计数已经在攻击者手里了,
|
||
# 报出来的数字会变成「还差几次就能把这个人锁住」的倒计时。
|
||
flash("%s(本来源连续失败 %d 次)" % (err, n), "error")
|
||
# 必须把 next 显式回填:失败后 request.args 为空,
|
||
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
|
||
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(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)
|
||
|
||
security.note_try(ip)
|
||
left = security.auth_locked(ip, username)
|
||
if left:
|
||
reason = security.auth_block_reason(ip, username)
|
||
flash(security.auth_block_message(reason, 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")
|
||
@login_required
|
||
def logout_post():
|
||
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
|
||
u = current_user()
|
||
if u:
|
||
db.audit(db.get_db(), "logout", u["username"], "", _ip(), u["id"])
|
||
security.logout_session()
|
||
flash("已退出登录", "ok")
|
||
return redirect(url_for("views.login"))
|
||
|
||
|
||
@bp.get("/logout")
|
||
def logout():
|
||
"""保留 GET 入口:老书签/旧页面不会 405,但只做跳转不执行退出。"""
|
||
if current_user():
|
||
flash("为安全起见,退出登录请点页面右上角的「退出」按钮", "warn")
|
||
return redirect(url_for("views.overview"))
|
||
return redirect(url_for("views.login"))
|
||
|
||
|
||
# ---------------- 概览 ----------------
|
||
@bp.get("/")
|
||
@login_required
|
||
def overview():
|
||
conn = db.get_db()
|
||
uid = _uid()
|
||
mf = query.manifest(conn, uid)
|
||
t = query.totals(conn, uid)
|
||
today = db.now_str()[:10]
|
||
st = query.summary(conn, uid, today, today)
|
||
d30 = query.summary(conn, uid, _shift(-29), today)
|
||
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
|
||
y = _shift(-1)
|
||
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 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, uid), active="overview")
|
||
|
||
|
||
# ---------------- 大屏(独立 ECharts 页)----------------
|
||
@bp.get("/dashboard")
|
||
@bp.get("/dashboard/")
|
||
@login_required
|
||
def dashboard():
|
||
# 大屏是独立静态页,但它也是导航的一项 —— 用 redirect 到一个带 active 的
|
||
# 路由做不到(静态页由 send_from_directory 直接吐文件),所以这里只负责发文件;
|
||
# 导航高亮改为让大屏页自己提供「返回后台」入口(见 index.html 的 topbar)。
|
||
return send_from_directory(os.path.join(current_app.static_folder, "dashboard"), "index.html")
|
||
|
||
|
||
# ---------------- 任务管理 ----------------
|
||
def _sch_info(conn, uid):
|
||
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()
|
||
return {
|
||
"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),
|
||
"interval": sch.interval,
|
||
"last": dict(last) if last else None,
|
||
"lock": os.path.exists(collect.LOCK_PATH),
|
||
"now": db.now_str(),
|
||
}
|
||
|
||
|
||
@bp.get("/tasks")
|
||
@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 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)
|
||
# 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位,
|
||
# 而不是让用户填完了再吃一个 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),
|
||
active="tasks")
|
||
|
||
|
||
def _int_arg(name, default, lo, hi):
|
||
"""健壮地取整数查询参数:非法值回落到 default,不抛异常(历史 500 来源)。"""
|
||
try:
|
||
return max(lo, min(hi, int(request.args.get(name) or default)))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _page_window(page, pages, span=9):
|
||
"""分页器只渲染当前页附近的一段,避免几百个页码链接把页面撑长。"""
|
||
if pages <= span:
|
||
return list(range(1, pages + 1))
|
||
half = span // 2
|
||
lo = max(1, min(page - half, pages - span + 1))
|
||
return list(range(lo, lo + span))
|
||
|
||
|
||
# ---------------- 配置管理 ----------------
|
||
@bp.get("/config")
|
||
@login_required
|
||
def config_page():
|
||
conn = db.get_db()
|
||
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)
|
||
# 加密键在 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 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(), allow_register=db.get_bool(
|
||
conn, "allow_register", True),
|
||
active="users")
|
||
|
||
|
||
# ---------------- 日志管理(仅管理员) ----------------
|
||
@bp.get("/logs")
|
||
@admin_required
|
||
def logs():
|
||
"""日志管理 —— **仅管理员**。
|
||
|
||
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
|
||
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
|
||
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
|
||
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
|
||
"""
|
||
conn = db.get_db()
|
||
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()
|
||
|
||
status = request.args.get("status") or ""
|
||
if status in ("ok", "warn", "error", "running"):
|
||
w, p = "WHERE status = ?", [status]
|
||
else:
|
||
status, w, p = "", "", []
|
||
|
||
act = request.args.get("act") or ""
|
||
ap = [act] if act else []
|
||
aw_sql = "WHERE action = ?" if act else ""
|
||
|
||
apage = _int_arg("apage", 1, 1, 10 ** 6)
|
||
asize = 20
|
||
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()
|
||
|
||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||
size = 30
|
||
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
||
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
|
||
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
|
||
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
|
||
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
|
||
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
|
||
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
|
||
p + [size, (page - 1) * size]).fetchall()
|
||
apages = max(1, (atotal + asize - 1) // asize)
|
||
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
|
||
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
|
||
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, 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):
|
||
return jsonify({"lines": [], "path": path, "size": 0})
|
||
# 只保留尾部 n 行,不把整个日志文件读进内存
|
||
from collections import deque
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
lines = list(deque(f, maxlen=n))
|
||
return jsonify({"lines": [l.rstrip("\n") for l in lines], "path": path,
|
||
"size": os.path.getsize(path)})
|
||
|
||
|
||
# ---------------- 数据明细 ----------------
|
||
def _day_args():
|
||
"""取明细页的日期区间。
|
||
|
||
规范参数名是 from / to,但模板内部为了避免与 Python 关键字混淆用的是
|
||
frm,历史上也出现过 `?frm=` 的链接,这里一并接受,避免导出/翻页丢筛选条件。
|
||
"""
|
||
a = request.args
|
||
frm = query.norm_day(a.get("from") or a.get("frm"))
|
||
to = query.norm_day(a.get("to"))
|
||
if frm and to and frm > to:
|
||
frm, to = to, frm
|
||
return frm, to
|
||
|
||
|
||
@bp.get("/records")
|
||
@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
|
||
q = request.args.get("q") or None
|
||
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, 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, uid)
|
||
models = [r["name"] for r in d["model"]]
|
||
clients = [r["name"] for r in d["client"]]
|
||
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
|
||
# dict.items 这个方法而不是数据,所以这里拆出独立变量。
|
||
return render_template("records.html", data=data, rows=data["items"], models=models,
|
||
clients=clients,
|
||
page_window=_page_window(page, data["pages"]),
|
||
f=dict(frm=frm or "", to=to or "", model=model or "",
|
||
client=client or "", q=q or "", order=order, size=size),
|
||
active="records")
|
||
|
||
|
||
@bp.get("/records/export")
|
||
@login_required
|
||
def records_export():
|
||
"""按当前筛选条件导出 CSV(与官网 xlsx 同构的列)。
|
||
|
||
用 csv 模块逐行写(原来手工拼字符串,model/client 含逗号或引号时会串列),
|
||
数据用 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
|
||
q = request.args.get("q") or None
|
||
order = request.args.get("order") or "ts_desc"
|
||
|
||
def gen():
|
||
yield "\ufeff" # UTF-8 BOM:Excel 直接双击不乱码
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf, lineterminator="\r\n")
|
||
w.writerow(collect.FIELDS)
|
||
yield buf.getvalue()
|
||
# 重要:流式响应的 body 是在视图返回、请求上下文被 pop(teardown 里
|
||
# close_db 已把 g.db 关掉)之后才被 WSGI 服务器逐块拉取的,
|
||
# 所以这里绝不能复用 db.get_db() 的连接——那会直接
|
||
# `sqlite3.ProgrammingError: Cannot operate on a closed database`。
|
||
# 自己开一条连接,并在流结束时关掉。
|
||
own = db.connect()
|
||
try:
|
||
for r in query.iter_records(own, uid, frm, to, model=model, client=client,
|
||
q=q, order=order):
|
||
buf.seek(0)
|
||
buf.truncate(0)
|
||
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
|
||
r["model"], r["client"], r["ts"]])
|
||
yield buf.getvalue()
|
||
finally:
|
||
own.close()
|
||
|
||
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
|
||
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 之类的前置代理先缓冲整个响应体
|
||
resp.headers["X-Accel-Buffering"] = "no"
|
||
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():
|
||
return redirect(url_for("views.dashboard"))
|