diff --git a/.env.example b/.env.example index e872f9b..852a718 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,9 @@ # - 若该用户已存在且设置 NAV_ADMIN_UPDATE_PASSWORD=1:用 NAV_ADMIN_PASSWORD 覆盖其密码(改完请删去或置 0,避免每次启动重置)。 # NAV_ADMIN_UPDATE_PASSWORD=1 -# 数据库(默认 SQLite 文件在当前工作目录) +# 数据库(默认 SQLite 文件在当前工作目录;生产建议放 instance/ 且勿提交 Git) # NAV_DATABASE_URL=sqlite:///nav_local.db +# NAV_DATABASE_URL=sqlite:///instance/nav_local.db # 仅 python app.py 直连启动时生效 # NAV_HOST=0.0.0.0 diff --git a/app.py b/app.py index 53048b7..be99465 100644 --- a/app.py +++ b/app.py @@ -93,6 +93,103 @@ def _apply_session_cookie_settings(app: Flask) -> None: app.config["REMEMBER_COOKIE_SECURE"] = False +_HUB_SESSION_COOKIE = "hub_sess" + + +def _hub_api_login(base: str, username: str, password: str) -> tuple[str | None, str | None]: + """服务端登录中控,返回 (session_token, error_detail)。""" + payload = json.dumps({"username": username, "password": password}).encode("utf-8") + req = urllib.request.Request( + f"{base.rstrip('/')}/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 None, detail or "中控登录失败" + except Exception as e: + return None, f"无法连接中控: {e}" + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None, "中控返回非 JSON" + if not data.get("ok"): + return None, data.get("detail") or "登录失败" + token = (data.get("session_token") or "").strip() + if not token: + return None, "中控未返回 session_token,请升级云端 hub 后重试" + return token, None + + +def _hub_instance_open_url( + base: str, session_token: str, exchange_id: str, next_path: str, *, embed: bool = True +) -> tuple[str | None, str | None]: + """已登录中控会话下签发实例 SSO 打开链接。""" + params = {"exchange_id": str(exchange_id), "next": next_path or "/"} + if embed: + params["embed"] = "1" + q = urlencode(params) + req = urllib.request.Request( + f"{base.rstrip('/')}/api/instance/open-url?{q}", + headers={ + "Accept": "application/json", + "Cookie": f"{_HUB_SESSION_COOKIE}={session_token}", + }, + ) + 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 None, detail or "无法签发实例链接" + except Exception as e: + return None, f"无法连接中控: {e}" + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None, "中控返回非 JSON" + url = (data.get("url") or "").strip() + if not data.get("ok") or not url: + return None, data.get("detail") or "无法签发实例链接" + return url, None + + +def _resolve_hub_embed_service(body: dict) -> tuple[str | None, str | None, str | None]: + """返回 (base_origin, default_next_path, error_detail)。""" + 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 + + if sid: + svc = db.session.get(Service, int(sid)) + if not svc or not svc.is_hub_embed(): + return None, None, "服务不存在或未标记为中控" + base = svc.build_origin() + if not body.get("next") or next_path == "/monitor": + p = (svc.path or "/monitor").strip() or "/monitor" + next_path = p if p.startswith("/") else "/" + p + + if not base: + return None, None, "缺少 base_url" + return base, next_path, None + + def create_app() -> Flask: app = Flask(__name__) app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32) @@ -178,65 +275,71 @@ def create_app() -> Flask: 避免浏览器在跨站 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 + base, next_path, err = _resolve_hub_embed_service(body) + if err: + return jsonify({"ok": False, "detail": err}), 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 + token, err = _hub_api_login(base, username, password) + if err: + status = 401 if "登录" in err or "401" in err else 502 + return jsonify({"ok": False, "detail": err}), status 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("/api/embed/hub-instance-url", methods=["POST"]) + @csrf.exempt + @login_required + def api_embed_hub_instance_url(): + """ + 本地导航代签实例 SSO:服务端登录中控后调用 /api/instance/open-url。 + 用于 LocalNav iframe 直接打开实例(避免中控内再嵌一层 iframe)。 + """ + body = request.get_json(silent=True) or {} + exchange_id = str(body.get("exchange_id") or "").strip() + next_path = (body.get("next") or "/").strip() or "/" + if not next_path.startswith("/"): + next_path = "/" + next_path + if not exchange_id: + return jsonify({"ok": False, "detail": "缺少 exchange_id"}), 400 + + 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) + + base, _, err = _resolve_hub_embed_service(body) + if err: + return jsonify({"ok": False, "detail": err}), 400 + if not username or not password: + return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400 + + token, err = _hub_api_login(base, username, password) + if err: + status = 401 if "登录" in err or "401" in err else 502 + return jsonify({"ok": False, "detail": err}), status + + url, err = _hub_instance_open_url( + base, + token, + exchange_id, + next_path, + embed=(body.get("embed") or "1").strip().lower() not in ("0", "false", "no"), + ) + if err: + return jsonify({"ok": False, "detail": err}), 502 + return jsonify({"ok": True, "url": url, "exchange_id": exchange_id, "next": next_path}) + # ---------- 分组管理 ---------- @app.route("/admin/groups") @login_required diff --git a/instance/nav_local.db b/instance/nav_local.db deleted file mode 100644 index 932131d..0000000 Binary files a/instance/nav_local.db and /dev/null differ diff --git a/models.py b/models.py index 47a3ddc..32d816e 100644 --- a/models.py +++ b/models.py @@ -54,7 +54,10 @@ class Service(db.Model): proto = (self.scheme or "http").strip().lower() if proto not in ("http", "https"): proto = "http" - return f"{proto}://{self.host}:{self.port}" + port = int(self.port or (443 if proto == "https" else 80)) + if (proto == "https" and port == 443) or (proto == "http" and port == 80): + return f"{proto}://{self.host}" + return f"{proto}://{self.host}:{port}" def build_url(self) -> str: p = (self.path or "/").strip() diff --git a/static/style.css b/static/style.css index 9b40ed2..6a91a4f 100644 --- a/static/style.css +++ b/static/style.css @@ -348,6 +348,19 @@ a:hover { background: rgba(61, 139, 253, 0.1); } +#frame-back-hub { + color: #9ecbff; + border-color: rgba(61, 139, 253, 0.45); + background: rgba(61, 139, 253, 0.12); + font-weight: 600; +} + +#frame-back-hub:hover { + color: #fff; + border-color: #3d8bfd; + background: rgba(61, 139, 253, 0.22); +} + .frame-title { font-size: 0.92rem; font-weight: 600; diff --git a/templates/index.html b/templates/index.html index edccf63..984a714 100644 --- a/templates/index.html +++ b/templates/index.html @@ -106,6 +106,15 @@ +
@@ -118,6 +127,15 @@ > 中控登录 + @@ -132,7 +150,7 @@
- +
@@ -181,18 +199,55 @@ var btnRefresh = document.getElementById("frame-refresh"); var btnForceRefresh = document.getElementById("frame-force-refresh"); var btnBack = document.getElementById("frame-back-overview"); + var btnBackHub = document.getElementById("frame-back-hub"); var btnHubLogin = document.getElementById("frame-hub-login"); + var btnInstanceSso = document.getElementById("frame-instance-sso"); var currentBaseUrl = ""; var currentOpenUrl = ""; var currentEmbedKind = ""; var currentServiceId = ""; var currentOrigin = ""; var currentNextPath = "/monitor"; + var currentViewMode = "service"; + var hubReturnState = null; + var instanceNavCtx = null; + + function normalizeOrigin(raw) { + if (!raw) return ""; + try { + var u = new URL(raw.indexOf("://") >= 0 ? raw : "http://" + raw); + var port = u.port; + if (u.protocol === "https:" && (!port || port === "443")) { + return u.protocol + "//" + u.hostname; + } + if (u.protocol === "http:" && (!port || port === "80")) { + return u.protocol + "//" + u.hostname; + } + return u.origin; + } catch (e) { + return String(raw).replace(/\/+$/, ""); + } + } + + function originsCompatible(expected, actual) { + var e = normalizeOrigin(expected); + var a = normalizeOrigin(actual); + if (!e || !a) return true; + return e === a; + } function isHubEmbed(kind) { return (kind || "").toLowerCase() === "hub"; } + function toggleInstanceBackBtn(show) { + if (btnBackHub) btnBackHub.hidden = !show; + } + + function toggleInstanceSsoBtn(show) { + if (btnInstanceSso) btnInstanceSso.hidden = !show; + } + function toggleHubLoginBtn(show) { if (btnHubLogin) btnHubLogin.hidden = !show; } @@ -202,6 +257,48 @@ frame.src = url; } + function iframeLooksLikeHub(href) { + if (!href) return false; + var hubOrigin = normalizeOrigin(currentOrigin); + var h = String(href); + if (hubOrigin && h.indexOf(hubOrigin) !== 0) return false; + return ( + /\/monitor(\?|#|$)/.test(h) || + h.indexOf("/embed-auth") >= 0 || + (h.indexOf("/login") >= 0 && h.indexOf("embed=1") >= 0) || + h.indexOf("/settings") >= 0 + ); + } + + function syncHubInstanceBackBtn() { + if (!isHubEmbed(currentEmbedKind) || frameStack.hidden || !currentBaseUrl) { + toggleInstanceBackBtn(false); + toggleInstanceSsoBtn(false); + return; + } + var onHub = false; + try { + onHub = iframeLooksLikeHub(frame.contentWindow.location.href); + } catch (e) { + onHub = false; + } + if (onHub) { + currentViewMode = "hub"; + toggleInstanceBackBtn(false); + toggleInstanceSsoBtn(false); + toggleHubLoginBtn(true); + return; + } + currentViewMode = "hub-instance"; + toggleInstanceBackBtn(true); + toggleHubLoginBtn(false); + toggleInstanceSsoBtn(!!(instanceNavCtx && instanceNavCtx.exchangeId)); + } + + if (frame) { + frame.addEventListener("load", syncHubInstanceBackBtn); + } + function hubLoginViaProxy(done) { if (!currentServiceId && !currentOrigin) { if (done) done(false, "未选择中控服务"); @@ -234,12 +331,127 @@ 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); + if (!data || !data.type) return; + if (data.type === "hub:login-ok") { + if (data.embed_auth_url) { + applyIframeUrl(data.embed_auth_url); + } + return; } + if (data.type === "hub:open-instance-nav") { + instanceNavCtx = { + exchangeId: String(data.exchangeId || ""), + nextPath: data.nextPath || "/", + title: data.title || "交易所实例", + serviceId: currentServiceId, + }; + currentViewMode = "hub-instance"; + if (data.title) nameEl.textContent = data.title; + toggleHubLoginBtn(false); + toggleInstanceBackBtn(true); + toggleInstanceSsoBtn(!!instanceNavCtx.exchangeId); + return; + } + if (data.type !== "hub:open-instance") return; + if (frameStack.hidden || !currentServiceId) return; + if (!originsCompatible(currentOrigin, ev.origin)) { + console.warn( + "[LocalNav] hub:open-instance origin 不匹配,已忽略", + normalizeOrigin(currentOrigin), + normalizeOrigin(ev.origin) + ); + return; + } + if (!data.url) return; + + try { + if (ev.source) { + ev.source.postMessage({ type: "hub:open-instance-ack", ok: true }, ev.origin || "*"); + } + } catch (e) {} + + hubReturnState = { + openUrl: currentOpenUrl, + baseUrl: currentBaseUrl, + name: nameEl.textContent, + embedKind: currentEmbedKind, + serviceId: currentServiceId, + origin: currentOrigin, + nextPath: currentNextPath, + }; + instanceNavCtx = { + exchangeId: String(data.exchangeId || ""), + nextPath: data.nextPath || "/", + title: data.title || "交易所实例", + serviceId: currentServiceId, + }; + currentViewMode = "hub-instance"; + nameEl.textContent = instanceNavCtx.title; + toggleHubLoginBtn(false); + toggleInstanceBackBtn(true); + applyIframeUrl(data.url); }); + function refreshInstanceViaProxy(done) { + if (!instanceNavCtx || !instanceNavCtx.exchangeId) { + if (done) done(false, null, "缺少实例上下文"); + return; + } + fetch("/api/embed/hub-instance-url", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ + service_id: parseInt(instanceNavCtx.serviceId, 10) || undefined, + exchange_id: instanceNavCtx.exchangeId, + next: instanceNavCtx.nextPath || "/", + embed: "1", + }), + }) + .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.url) { + if (done) done(true, res.j.url, null); + return; + } + if (done) done(false, null, (res.j && res.j.detail) || "无法重新打开实例"); + }) + .catch(function (e) { + if (done) done(false, null, String(e)); + }); + } + + function returnToHubMonitor() { + var st = hubReturnState; + currentViewMode = "hub"; + instanceNavCtx = null; + hubReturnState = null; + toggleInstanceBackBtn(false); + toggleInstanceSsoBtn(false); + if (st) { + currentOpenUrl = st.openUrl || currentOpenUrl; + currentBaseUrl = st.baseUrl || st.openUrl || currentBaseUrl; + currentEmbedKind = st.embedKind || currentEmbedKind; + currentServiceId = st.serviceId || currentServiceId; + currentOrigin = st.origin || currentOrigin; + currentNextPath = st.nextPath || currentNextPath; + nameEl.textContent = st.name || nameEl.textContent; + } + toggleHubLoginBtn(isHubEmbed(currentEmbedKind)); + if (isHubEmbed(currentEmbedKind) && hubAutoLogin) { + hubLoginViaProxy(function (ok) { + if (!ok) applyIframeUrl(currentOpenUrl || currentBaseUrl); + syncHubInstanceBackBtn(); + }); + return; + } + applyIframeUrl(currentOpenUrl || currentBaseUrl); + syncHubInstanceBackBtn(); + } + function setActive(el) { links.forEach(function (a) { a.classList.remove("active"); @@ -264,6 +476,10 @@ currentServiceId = meta.serviceId || ""; currentOrigin = meta.origin || ""; currentNextPath = meta.nextPath || "/monitor"; + currentViewMode = isHubEmbed(currentEmbedKind) ? "hub" : "service"; + instanceNavCtx = null; + hubReturnState = null; + toggleInstanceBackBtn(false); nameEl.textContent = name || ""; dashboard.hidden = true; frameStack.hidden = false; @@ -307,12 +523,33 @@ } function reloadUrl() { + if (currentViewMode === "hub-instance") { + refreshInstanceViaProxy(function (ok, url, err) { + if (ok && url) applyIframeUrl(url); + else if (err) window.alert(err); + }); + return; + } var u = currentOpenUrl || currentBaseUrl; if (!u) return; frame.src = buildCacheBustUrl(u, false); } function forceReloadUrl() { + if (currentViewMode === "hub-instance") { + refreshInstanceViaProxy(function (ok, url, err) { + if (!ok || !url) { + if (err) window.alert(err); + return; + } + frame.src = "about:blank"; + frame.onload = function () { + frame.onload = null; + frame.src = buildCacheBustUrl(url, true); + }; + }); + return; + } var u = currentOpenUrl || currentBaseUrl; if (!u) return; frame.src = "about:blank"; @@ -328,11 +565,15 @@ currentEmbedKind = ""; currentServiceId = ""; currentOrigin = ""; + currentViewMode = "service"; + instanceNavCtx = null; + hubReturnState = null; frame.src = "about:blank"; frame.hidden = true; frameStack.hidden = true; dashboard.hidden = false; toggleHubLoginBtn(false); + toggleInstanceSsoBtn(false); setActive(null); } @@ -368,7 +609,24 @@ btnHubLogin.disabled = true; hubLoginViaProxy(function (ok, err) { btnHubLogin.disabled = false; - if (!ok && err) window.alert(err); + if (!ok && err) { + window.alert("中控登录失败:\n" + err + "\n\n请检查 LocalNav .env 的 NAV_HUB_USERNAME / NAV_HUB_PASSWORD 是否与云端 hub .env 一致。"); + } + }); + }); + } + + if (btnInstanceSso) { + btnInstanceSso.addEventListener("click", function () { + btnInstanceSso.disabled = true; + refreshInstanceViaProxy(function (ok, url, err) { + btnInstanceSso.disabled = false; + if (ok && url) { + applyIframeUrl(url); + syncHubInstanceBackBtn(); + return; + } + if (err) window.alert("实例免密失败:\n" + err); }); }); } @@ -384,6 +642,12 @@ btnBack.addEventListener("click", function () { showDashboard(); }); + + if (btnBackHub) { + btnBackHub.addEventListener("click", function () { + returnToHubMonitor(); + }); + } })(); {% endblock %} diff --git a/部署与使用说明.md b/部署与使用说明.md index 95c25d2..8ca5f9b 100644 --- a/部署与使用说明.md +++ b/部署与使用说明.md @@ -264,7 +264,17 @@ HUB_EMBED_ORIGINS=http://192.168.8.6:5070 将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。 -**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。 +**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接(避免「导航 → 中控 → 实例」三层嵌套导致 Cookie 失效、反复要密码)。工具栏会出现 **「← 中控」** 返回监控区;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`)。 + +四实例 `.env` 建议: + +```env +APP_COOKIE_SECURE=true +APP_ALLOW_HUB_EMBED=true +HUB_EMBED_PARENT_ORIGINS=https://你的中控域名,http://192.168.x.x:5070 +``` + +(`5070` 换成本地导航实际地址;与中控相同的 `HUB_BRIDGE_TOKEN` 必填。) ---