主站 - 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号 同一事务,并发提交只有一个能成功 - 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据 中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取 - 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为 「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」; 随之清理陈旧 CSS 数据中枢 - 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验 主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站 - 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取 /models,失败退回卡内手动录入)、会员管理与邀请码页 - 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量 自测 - 主站 verify_baseline 通过(498 项);数据中枢 235 项通过 - tools/verify_datahub_console.py 端到端跑通两服务真实对话; tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏 Co-authored-by: multica-agent <github@multica.ai>
180 lines
6.6 KiB
Python
180 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
from datahub.logutil import get_logger
|
|
|
|
LOGGER = get_logger()
|
|
|
|
SITE_SESSION_COOKIE = "xiaobai_session"
|
|
VERIFY_CACHE_SECONDS = 20.0
|
|
BRIDGE_TIMEOUT_SECONDS = 6.0
|
|
|
|
|
|
class SiteBridgeError(RuntimeError):
|
|
"""The review site could not answer a bridge call.
|
|
|
|
``status`` carries the review site's HTTP status when it answered with one.
|
|
A 4xx there means the operator's input was rejected (bad API key, invalid
|
|
model id), which must not surface here as a console fault.
|
|
"""
|
|
|
|
def __init__(self, message: str, status: int = 0) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
@property
|
|
def caller_fault(self) -> bool:
|
|
return 400 <= self.status < 500
|
|
|
|
|
|
class SiteBridge:
|
|
"""Service-to-service client for the review site's ``/api/hub-admin/*`` endpoints.
|
|
|
|
The shared ``HUB_ADMIN_TOKEN`` is the only credential; the review site
|
|
checks it before any handler runs, so nothing here needs a browser session.
|
|
"""
|
|
|
|
def __init__(self, base_url: str, token: str, timeout: float = BRIDGE_TIMEOUT_SECONDS) -> None:
|
|
self.base_url = (base_url or "").rstrip("/")
|
|
self.token = token or ""
|
|
self.timeout = timeout
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self.base_url and self.token)
|
|
|
|
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if not self.configured:
|
|
raise SiteBridgeError("主站桥接未配置:请设置 REVIEW_BASE_URL 与 HUB_ADMIN_TOKEN")
|
|
body = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
f"{self.base_url}{path}",
|
|
data=body,
|
|
method="POST",
|
|
headers={
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"X-Hub-Admin-Token": self.token,
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
raw = response.read()
|
|
except urllib.error.HTTPError as exc:
|
|
detail = _error_detail(exc.read())
|
|
raise SiteBridgeError(detail or f"主站返回 {exc.code}", exc.code) from exc
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
raise SiteBridgeError(f"主站不可达:{exc}") from exc
|
|
try:
|
|
parsed = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SiteBridgeError("主站返回的不是合法 JSON") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise SiteBridgeError("主站返回的不是合法 JSON")
|
|
if parsed.get("error"):
|
|
raise SiteBridgeError(str(parsed["error"]))
|
|
return parsed
|
|
|
|
|
|
def _error_detail(raw: bytes) -> str:
|
|
try:
|
|
parsed = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return ""
|
|
if isinstance(parsed, dict) and parsed.get("error"):
|
|
return str(parsed["error"])
|
|
return ""
|
|
|
|
|
|
class SiteAuth:
|
|
"""Admin gate for the console: the review site owns accounts, we only verify.
|
|
|
|
The console has no accounts of its own. Every request carries the review
|
|
site's ``xiaobai_session`` cookie (same host, different port, so the browser
|
|
sends it), which we hand to the site for verification. Results are cached
|
|
for a few seconds so a page full of panels does not fan out one bridge call
|
|
per request.
|
|
|
|
CSRF is stateless: the token is an HMAC of the session token under a server
|
|
secret, so it is unguessable without the secret yet needs no storage and
|
|
stays valid exactly as long as the session does.
|
|
"""
|
|
|
|
def __init__(self, bridge: SiteBridge, secret: str, cache_seconds: float = VERIFY_CACHE_SECONDS) -> None:
|
|
self.bridge = bridge
|
|
self._secret = (secret or "").encode("utf-8")
|
|
self._cache_seconds = cache_seconds
|
|
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def verify(self, session_token: str) -> dict[str, Any] | None:
|
|
if not session_token:
|
|
return None
|
|
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
cached = self._cache.get(key)
|
|
if cached and cached[0] > now:
|
|
return cached[1]
|
|
user = self._verify_remote(session_token)
|
|
with self._lock:
|
|
self._cache[key] = (now + self._cache_seconds, user)
|
|
if len(self._cache) > 256:
|
|
self._prune(now)
|
|
return user
|
|
|
|
def _prune(self, now: float) -> None:
|
|
for cached_key in [key for key, (expires, _) in self._cache.items() if expires <= now]:
|
|
self._cache.pop(cached_key, None)
|
|
|
|
def _verify_remote(self, session_token: str) -> dict[str, Any] | None:
|
|
payload = self.bridge.call("/api/hub-admin/session", {"session_token": session_token})
|
|
if not payload.get("authenticated"):
|
|
return None
|
|
user = payload.get("user") or {}
|
|
return {
|
|
"id": int(user.get("id") or 0),
|
|
"username": str(user.get("username") or ""),
|
|
"role": str(user.get("role") or "user"),
|
|
"is_admin": bool(user.get("is_admin")),
|
|
}
|
|
|
|
def invalidate(self, session_token: str) -> None:
|
|
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
|
|
with self._lock:
|
|
self._cache.pop(key, None)
|
|
|
|
def csrf_token(self, session_token: str) -> str:
|
|
digest = hashlib.sha256(session_token.encode("utf-8")).digest()
|
|
return hmac.new(self._secret, digest, hashlib.sha256).hexdigest()
|
|
|
|
def check_csrf(self, session_token: str, supplied: str) -> bool:
|
|
if not supplied:
|
|
return False
|
|
return hmac.compare_digest(self.csrf_token(session_token), supplied)
|
|
|
|
def logout(self, session_token: str) -> None:
|
|
self.invalidate(session_token)
|
|
if not session_token:
|
|
return
|
|
try:
|
|
self.bridge.call("/api/hub-admin/session/logout", {"session_token": session_token})
|
|
except SiteBridgeError:
|
|
LOGGER.warning("site logout bridge call failed")
|
|
|
|
def confirm_password(self, user_id: int, password: str) -> bool:
|
|
if not password:
|
|
return False
|
|
payload = self.bridge.call(
|
|
"/api/hub-admin/password/check",
|
|
{"user_id": int(user_id), "password": password},
|
|
)
|
|
return bool(payload.get("verified"))
|