修复
This commit is contained in:
@@ -34,3 +34,10 @@
|
||||
# NAV_SESSION_COOKIE_SECURE=auto
|
||||
# CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如:
|
||||
# NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com
|
||||
|
||||
# ---------- 云端复盘中控 iframe 嵌入(与 manual_trading_hub 配合)----------
|
||||
# 本地导航代登录中控(服务端请求云端 /api/auth/login,再打开 /embed-auth)
|
||||
# NAV_HUB_USERNAME=admin
|
||||
# NAV_HUB_PASSWORD=你的中控密码
|
||||
# 打开标记为「复盘中控」的服务时自动代登录(1=开启)
|
||||
# NAV_HUB_AUTO_LOGIN=1
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Flask, flash, redirect, render_template, request, url_for
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -159,7 +163,79 @@ def create_app() -> Flask:
|
||||
.all()
|
||||
)
|
||||
grouped.append((g, svcs))
|
||||
return render_template("index.html", grouped=grouped)
|
||||
return render_template(
|
||||
"index.html",
|
||||
grouped=grouped,
|
||||
hub_auto_login=os.environ.get("NAV_HUB_AUTO_LOGIN", "").strip() == "1",
|
||||
)
|
||||
|
||||
@app.route("/api/embed/hub-login", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def api_embed_hub_login():
|
||||
"""
|
||||
本地导航代登录云端中控:服务端请求 hub /api/auth/login,返回 embed-auth URL。
|
||||
避免浏览器在跨站 iframe 里丢弃 Set-Cookie。
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
sid = body.get("service_id")
|
||||
base = (body.get("base_url") or "").strip().rstrip("/")
|
||||
next_path = (body.get("next") or "/monitor").strip() or "/monitor"
|
||||
if not next_path.startswith("/"):
|
||||
next_path = "/" + next_path
|
||||
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
|
||||
password = body.get("password")
|
||||
if password is None:
|
||||
password = os.environ.get("NAV_HUB_PASSWORD") or ""
|
||||
password = str(password)
|
||||
|
||||
if sid:
|
||||
svc = db.session.get(Service, int(sid))
|
||||
if not svc or not svc.is_hub_embed():
|
||||
return jsonify({"ok": False, "detail": "服务不存在或未标记为中控"}), 400
|
||||
base = svc.build_origin()
|
||||
if not next_path or next_path == "/monitor":
|
||||
p = (svc.path or "/monitor").strip() or "/monitor"
|
||||
next_path = p if p.startswith("/") else "/" + p
|
||||
|
||||
if not base:
|
||||
return jsonify({"ok": False, "detail": "缺少 base_url"}), 400
|
||||
if not username or not password:
|
||||
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
|
||||
|
||||
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/auth/login",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8", errors="replace")
|
||||
detail = json.loads(err_body).get("detail", err_body)
|
||||
except Exception:
|
||||
detail = str(e)
|
||||
return jsonify({"ok": False, "detail": detail or "中控登录失败"}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "detail": f"无法连接中控: {e}"}), 502
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"ok": False, "detail": "中控返回非 JSON"}), 502
|
||||
if not data.get("ok"):
|
||||
return jsonify({"ok": False, "detail": data.get("detail") or "登录失败"}), 401
|
||||
token = data.get("session_token")
|
||||
if not token:
|
||||
return jsonify({"ok": False, "detail": "中控未返回 session_token,请升级云端 hub 后重试"}), 502
|
||||
|
||||
q = urlencode({"token": token, "next": next_path})
|
||||
embed_url = f"{base}/embed-auth?{q}"
|
||||
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
|
||||
|
||||
# ---------- 分组管理 ----------
|
||||
@app.route("/admin/groups")
|
||||
@@ -261,6 +337,7 @@ def create_app() -> Flask:
|
||||
path=path,
|
||||
sort_order=form.sort_order.data or 0,
|
||||
group_id=form.group_id.data,
|
||||
embed_kind=(form.embed_kind.data or "").strip(),
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
@@ -296,6 +373,7 @@ def create_app() -> Flask:
|
||||
s.path = (form.path.data or "").strip() or "/"
|
||||
s.sort_order = form.sort_order.data or 0
|
||||
s.group_id = form.group_id.data
|
||||
s.embed_kind = (form.embed_kind.data or "").strip()
|
||||
db.session.commit()
|
||||
flash("服务已更新", "success")
|
||||
return redirect(url_for("admin_services"))
|
||||
@@ -352,6 +430,16 @@ def _migrate_schema() -> None:
|
||||
)
|
||||
)
|
||||
print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True)
|
||||
cols = {c["name"] for c in insp.get_columns("services")}
|
||||
if "embed_kind" not in cols:
|
||||
with db.engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE services ADD COLUMN embed_kind VARCHAR(16) "
|
||||
"NOT NULL DEFAULT ''"
|
||||
)
|
||||
)
|
||||
print("[nav] 已为 services 表添加 embed_kind 列。", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
|
||||
|
||||
|
||||
@@ -45,6 +45,15 @@ class ServiceForm(FlaskForm):
|
||||
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
|
||||
default=0,
|
||||
)
|
||||
embed_kind = SelectField(
|
||||
"嵌入类型",
|
||||
choices=[
|
||||
("", "普通(直接打开路径)"),
|
||||
("hub", "复盘中控(云端 hub,需 embed-auth 登录)"),
|
||||
],
|
||||
default="",
|
||||
validators=[Optional()],
|
||||
)
|
||||
submit = SubmitField("保存")
|
||||
|
||||
def validate_path(self, field):
|
||||
|
||||
Binary file not shown.
@@ -47,12 +47,33 @@ class Service(db.Model):
|
||||
group_id = db.Column(
|
||||
db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True
|
||||
)
|
||||
# hub=复盘中控(iframe 嵌入需 /embed-auth);留空=普通内嵌
|
||||
embed_kind = db.Column(db.String(16), nullable=False, default="", server_default="")
|
||||
|
||||
def build_origin(self) -> str:
|
||||
proto = (self.scheme or "http").strip().lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
return f"{proto}://{self.host}:{self.port}"
|
||||
|
||||
def build_url(self) -> str:
|
||||
p = (self.path or "/").strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
proto = (self.scheme or "http").strip().lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
return f"{proto}://{self.host}:{self.port}{p}"
|
||||
return f"{self.build_origin()}{p}"
|
||||
|
||||
def build_open_url(self) -> str:
|
||||
"""导航 iframe 首次打开的地址(中控走 login?embed=1 以便写入会话)。"""
|
||||
kind = (self.embed_kind or "").strip().lower()
|
||||
next_path = (self.path or "/monitor").strip() or "/monitor"
|
||||
if not next_path.startswith("/"):
|
||||
next_path = "/" + next_path
|
||||
if kind == "hub":
|
||||
from urllib.parse import urlencode
|
||||
|
||||
q = urlencode({"embed": "1", "next": next_path})
|
||||
return f"{self.build_origin()}/login?{q}"
|
||||
return self.build_url()
|
||||
|
||||
def is_hub_embed(self) -> bool:
|
||||
return (self.embed_kind or "").strip().lower() == "hub"
|
||||
|
||||
@@ -70,6 +70,16 @@
|
||||
<div class="errors">{{ form.sort_order.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
{{ form.embed_kind.label }}
|
||||
{{ form.embed_kind() }}
|
||||
<p class="hint" style="margin:0.35rem 0 0">
|
||||
选「复盘中控」时:路径填 <code>/monitor</code>;本地 iframe 打开会先走中控登录页或代登录。
|
||||
</p>
|
||||
{% if form.embed_kind.errors %}
|
||||
<div class="errors">{{ form.embed_kind.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top: 1.25rem">
|
||||
{{ form.submit(class="btn btn-primary", style="width: auto") }}
|
||||
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<tbody>
|
||||
{% for s in services %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.name }}{% if s.embed_kind == 'hub' %} <span class="hint">(中控)</span>{% endif %}</td>
|
||||
<td><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
|
||||
<td>{{ s.group.name if s.group else '—' }}</td>
|
||||
<td>{{ s.sort_order }}</td>
|
||||
|
||||
+126
-9
@@ -41,7 +41,12 @@
|
||||
href="#"
|
||||
class="nav-link"
|
||||
role="button"
|
||||
data-url="{{ svc.build_url()|e }}"
|
||||
data-url="{{ svc.build_open_url()|e }}"
|
||||
data-base-url="{{ svc.build_url()|e }}"
|
||||
data-origin="{{ svc.build_origin()|e }}"
|
||||
data-next-path="{{ (svc.path or '/monitor')|e }}"
|
||||
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
||||
data-service-id="{{ svc.id }}"
|
||||
data-name="{{ svc.name | e }}"
|
||||
>{{ svc.name }}</a
|
||||
>
|
||||
@@ -75,7 +80,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="service-card"
|
||||
data-url="{{ svc.build_url()|e }}"
|
||||
data-url="{{ svc.build_open_url()|e }}"
|
||||
data-base-url="{{ svc.build_url()|e }}"
|
||||
data-origin="{{ svc.build_origin()|e }}"
|
||||
data-next-path="{{ (svc.path or '/monitor')|e }}"
|
||||
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
||||
data-service-id="{{ svc.id }}"
|
||||
data-name="{{ svc.name | e }}"
|
||||
>
|
||||
<span class="service-card-title">{{ svc.name }}</span>
|
||||
@@ -99,6 +109,15 @@
|
||||
<span class="frame-title" id="current-service-name"></span>
|
||||
</div>
|
||||
<div class="frame-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-toolbar-refresh"
|
||||
id="frame-hub-login"
|
||||
title="通过本地导航代登录云端中控(需配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"
|
||||
hidden
|
||||
>
|
||||
中控登录
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
||||
刷新
|
||||
</button>
|
||||
@@ -121,6 +140,7 @@
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
|
||||
var layoutMain = document.getElementById("layout-main");
|
||||
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
|
||||
var btnSidebarExpand = document.getElementById("sidebar-expand");
|
||||
@@ -161,7 +181,64 @@
|
||||
var btnRefresh = document.getElementById("frame-refresh");
|
||||
var btnForceRefresh = document.getElementById("frame-force-refresh");
|
||||
var btnBack = document.getElementById("frame-back-overview");
|
||||
var btnHubLogin = document.getElementById("frame-hub-login");
|
||||
var currentBaseUrl = "";
|
||||
var currentOpenUrl = "";
|
||||
var currentEmbedKind = "";
|
||||
var currentServiceId = "";
|
||||
var currentOrigin = "";
|
||||
var currentNextPath = "/monitor";
|
||||
|
||||
function isHubEmbed(kind) {
|
||||
return (kind || "").toLowerCase() === "hub";
|
||||
}
|
||||
|
||||
function toggleHubLoginBtn(show) {
|
||||
if (btnHubLogin) btnHubLogin.hidden = !show;
|
||||
}
|
||||
|
||||
function applyIframeUrl(url) {
|
||||
if (!url) return;
|
||||
frame.src = url;
|
||||
}
|
||||
|
||||
function hubLoginViaProxy(done) {
|
||||
if (!currentServiceId && !currentOrigin) {
|
||||
if (done) done(false, "未选择中控服务");
|
||||
return;
|
||||
}
|
||||
var body = { service_id: parseInt(currentServiceId, 10) || undefined, next: currentNextPath };
|
||||
fetch("/api/embed/hub-login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { ok: r.ok, j: j };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok && res.j.ok && res.j.embed_auth_url) {
|
||||
currentBaseUrl = res.j.embed_auth_url.split("?")[0].replace(/\/embed-auth$/, "") + (currentNextPath || "/monitor");
|
||||
applyIframeUrl(res.j.embed_auth_url);
|
||||
if (done) done(true);
|
||||
return;
|
||||
}
|
||||
if (done) done(false, (res.j && res.j.detail) || "中控登录失败");
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (done) done(false, String(e));
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (ev) {
|
||||
var data = ev.data;
|
||||
if (!data || data.type !== "hub:login-ok") return;
|
||||
if (data.embed_auth_url) {
|
||||
applyIframeUrl(data.embed_auth_url);
|
||||
}
|
||||
});
|
||||
|
||||
function setActive(el) {
|
||||
links.forEach(function (a) {
|
||||
@@ -178,14 +255,29 @@
|
||||
return found;
|
||||
}
|
||||
|
||||
function openService(url, name, preferredNav) {
|
||||
function openService(url, name, preferredNav, meta) {
|
||||
if (!url) return;
|
||||
currentBaseUrl = url;
|
||||
meta = meta || {};
|
||||
currentOpenUrl = url;
|
||||
currentBaseUrl = meta.baseUrl || url;
|
||||
currentEmbedKind = meta.embedKind || "";
|
||||
currentServiceId = meta.serviceId || "";
|
||||
currentOrigin = meta.origin || "";
|
||||
currentNextPath = meta.nextPath || "/monitor";
|
||||
nameEl.textContent = name || "";
|
||||
dashboard.hidden = true;
|
||||
frameStack.hidden = false;
|
||||
frame.hidden = false;
|
||||
frame.src = url;
|
||||
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
|
||||
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
|
||||
hubLoginViaProxy(function (ok, err) {
|
||||
if (!ok) applyIframeUrl(url);
|
||||
});
|
||||
var nav = preferredNav || findNavLink(url);
|
||||
setActive(nav);
|
||||
return;
|
||||
}
|
||||
applyIframeUrl(url);
|
||||
var nav = preferredNav || findNavLink(url);
|
||||
setActive(nav);
|
||||
}
|
||||
@@ -215,13 +307,13 @@
|
||||
}
|
||||
|
||||
function reloadUrl() {
|
||||
var u = currentBaseUrl;
|
||||
var u = currentOpenUrl || currentBaseUrl;
|
||||
if (!u) return;
|
||||
frame.src = buildCacheBustUrl(u, false);
|
||||
}
|
||||
|
||||
function forceReloadUrl() {
|
||||
var u = currentBaseUrl;
|
||||
var u = currentOpenUrl || currentBaseUrl;
|
||||
if (!u) return;
|
||||
frame.src = "about:blank";
|
||||
frame.onload = function () {
|
||||
@@ -232,19 +324,34 @@
|
||||
|
||||
function showDashboard() {
|
||||
currentBaseUrl = "";
|
||||
currentOpenUrl = "";
|
||||
currentEmbedKind = "";
|
||||
currentServiceId = "";
|
||||
currentOrigin = "";
|
||||
frame.src = "about:blank";
|
||||
frame.hidden = true;
|
||||
frameStack.hidden = true;
|
||||
dashboard.hidden = false;
|
||||
toggleHubLoginBtn(false);
|
||||
setActive(null);
|
||||
}
|
||||
|
||||
function readServiceMeta(el) {
|
||||
return {
|
||||
baseUrl: el.getAttribute("data-base-url") || el.getAttribute("data-url") || "",
|
||||
embedKind: el.getAttribute("data-embed-kind") || "",
|
||||
serviceId: el.getAttribute("data-service-id") || "",
|
||||
origin: el.getAttribute("data-origin") || "",
|
||||
nextPath: el.getAttribute("data-next-path") || "/monitor",
|
||||
};
|
||||
}
|
||||
|
||||
links.forEach(function (a) {
|
||||
a.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var url = a.getAttribute("data-url");
|
||||
var name = a.getAttribute("data-name") || "";
|
||||
openService(url, name, a);
|
||||
openService(url, name, a, readServiceMeta(a));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,10 +359,20 @@
|
||||
btn.addEventListener("click", function () {
|
||||
var url = btn.getAttribute("data-url");
|
||||
var name = btn.getAttribute("data-name") || "";
|
||||
openService(url, name, null);
|
||||
openService(url, name, null, readServiceMeta(btn));
|
||||
});
|
||||
});
|
||||
|
||||
if (btnHubLogin) {
|
||||
btnHubLogin.addEventListener("click", function () {
|
||||
btnHubLogin.disabled = true;
|
||||
hubLoginViaProxy(function (ok, err) {
|
||||
btnHubLogin.disabled = false;
|
||||
if (!ok && err) window.alert(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
btnRefresh.addEventListener("click", function () {
|
||||
reloadUrl();
|
||||
});
|
||||
|
||||
+30
@@ -236,6 +236,36 @@ http://<本机局域网IP>:5070
|
||||
|
||||
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。
|
||||
|
||||
### 8.6 云端「复盘中控」iframe 嵌入(LocalNav + manual_trading_hub)
|
||||
|
||||
本地导航(如 `http://192.168.x.x:5070`)嵌入 **云端中控**(`https://你的域名:5100`)时,浏览器会把中控 Cookie 视为**跨站第三方**,直接在 iframe 里登录常会「成功但进不去」。
|
||||
|
||||
**本地导航侧(本仓库)**
|
||||
|
||||
1. 「服务管理」→ 编辑中控条目 → **嵌入类型** 选 **「复盘中控」**,协议 HTTPS,路径填 `/monitor`。
|
||||
2. `.env` 配置(与云端中控账号一致):
|
||||
```env
|
||||
NAV_HUB_USERNAME=admin
|
||||
NAV_HUB_PASSWORD=你的中控密码
|
||||
NAV_HUB_AUTO_LOGIN=1
|
||||
```
|
||||
3. 重启 LocalNav。打开中控时会由**本地服务端**代登录,iframe 再打开 `/embed-auth?token=...` 写入会话。
|
||||
4. 也可在内嵌工具栏点 **「中控登录」** 手动触发。
|
||||
|
||||
**云端中控侧(`crypto_monitor/manual_trading_hub`)**
|
||||
|
||||
部署最新代码并重启 hub,`.env` 增加:
|
||||
|
||||
```env
|
||||
HUB_ALLOW_PUBLIC=true
|
||||
HUB_ALLOW_EMBED=true
|
||||
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
```
|
||||
|
||||
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
|
||||
|
||||
**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。
|
||||
|
||||
---
|
||||
|
||||
## 九、部署指南(以 Ubuntu 为例)
|
||||
|
||||
Reference in New Issue
Block a user