#!/usr/bin/env python3 """90CN GM 页本地托管 + 接口代理。 为什么需要这一层: 1. 客户端里那个窗口是 IE11 内核,页面要同源才能发 XHR,跨域到 18111 会被拦; 2. admin token 不应该写进页面 JS(会被任何人读到),由本进程在服务端注入。 用法: python serve.py # 默认 127.0.0.1:18112 python serve.py --port 18112 python serve.py --runtime Z:\\90server\\Server\\runtime 页面访问:http://127.0.0.1:18112/ 接口转发:/api/ -> http://127.0.0.1:18111/ """ from __future__ import annotations import argparse import json import os import re import sys import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib import error, request HERE = Path(__file__).resolve().parent MIME = { ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml", ".ico": "image/x-icon", } CONFIG = {"admin": "127.0.0.1:18111", "token": ""} # 关键:本机回环不能走系统/环境代理。环境里存在 http_proxy 时 urllib 会照单全收, # 结果连 127.0.0.1:18111 都被转发到代理,报 "upstream connect failed"。 OPENER = request.build_opener(request.ProxyHandler({})) # --------------------------------------------------------------------- 发现 def _candidate_runtimes() -> list[Path]: out: list[Path] = [] env = os.environ.get("DNF90_RUNTIME") if env: out.append(Path(env)) out += [ Path(r"Z:\90server\Server\runtime"), HERE.parent / "server" / "runtime", HERE.parent / "runtime", ] return out def discover_admin() -> tuple[str, str]: """从 instance.json / dnfbridge.toml 里读 adminListen 与 adminToken。""" token_env = os.environ.get("DNF90_ADMIN_TOKEN") for root in _candidate_runtimes(): inst = root / "config" / "instance.json" if inst.is_file(): try: cfg = json.loads(inst.read_text(encoding="utf-8")) srv = cfg.get("server", {}) listen = srv.get("adminListen") or CONFIG["admin"] token = token_env or srv.get("adminToken") or "" if token: return listen, token except (OSError, ValueError): pass toml = root / "configs" / "dnfbridge.toml" if toml.is_file(): try: txt = toml.read_text(encoding="utf-8", errors="ignore") except OSError: continue m = re.search(r'^\s*listen\s*=\s*"([^"]+)"', txt, re.M) listen = m.group(1) if m else CONFIG["admin"] m = re.search(r'^\s*token\s*=\s*"([^"]+)"', txt, re.M) token = token_env or (m.group(1) if m else "") if token: return listen, token return CONFIG["admin"], token_env or "" # --------------------------------------------------------------------- 代理 def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]: url = "http://%s%s" % (CONFIG["admin"], path) headers = {"X-Admin-Token": CONFIG["token"]} if method == "POST": headers["Content-Type"] = "application/json" # 服务端对写操作要求确认头 + 幂等键 headers["X-Admin-Confirm"] = "gmweb" headers["X-Admin-Idempotency-Key"] = str(uuid.uuid4()) req = request.Request(url, data=body, headers=headers, method=method) try: with OPENER.open(req, timeout=20) as resp: return resp.status, resp.read(), resp.headers.get("Content-Type", "application/json") except error.HTTPError as exc: return exc.code, exc.read(), exc.headers.get("Content-Type", "application/json") except error.URLError as exc: msg = json.dumps({"success": False, "message": "服务端未响应:%s" % exc.reason}, ensure_ascii=False).encode() return 502, msg, "application/json; charset=utf-8" # --------------------------------------------------------------------- 换装配置 def _loadout_config_path() -> Path: """LoadoutTool/config.json,优先部署根目录,其次运行目录。""" roots = [HERE.parent] env = os.environ.get("DNF90_SERVER_ROOT") if env: roots.insert(0, Path(env)) for root in roots: p = root / "LoadoutTool" / "config.json" if p.is_file(): return p return roots[0] / "LoadoutTool" / "config.json" def _default_loadout_config() -> dict: return { "ServerUrl": "http://127.0.0.1:18111", "AdminToken": "", "Pages": [{"Name": "默认页", "Schemes": []}], "ActivePage": 0, } def _read_loadout_config() -> dict: p = _loadout_config_path() try: if p.is_file(): data = json.loads(p.read_text(encoding="utf-8-sig")) if isinstance(data, dict): if not isinstance(data.get("Pages"), list) or not data["Pages"]: data["Pages"] = [{"Name": "默认页", "Schemes": []}] data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1)) # AdminToken 只留在本地文件,网页端永远拿不到。 data["AdminToken"] = "" return data except (OSError, ValueError, TypeError): pass return _default_loadout_config() def _write_loadout_config(data: dict) -> tuple[int, bytes, str]: if not isinstance(data, dict): return 400, b'{"ok":false,"error":"config must be an object"}', "application/json" pages = data.get("Pages") if not isinstance(pages, list) or not pages: return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json" p = _loadout_config_path() try: p.parent.mkdir(parents=True, exist_ok=True) # 网页提交的配置里没有 token;保留磁盘上原来的 token。 existing = {} if p.is_file(): try: existing = json.loads(p.read_text(encoding="utf-8-sig")) except (OSError, ValueError): existing = {} data = dict(data) data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else "" tmp = p.with_suffix(".json.tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(p) except OSError as exc: return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json" return 200, b'{"ok":true}', "application/json" def _loadout_hotkeys() -> dict: """Return the active page's configured global hotkeys for the UI hint.""" cfg = _read_loadout_config() pages = cfg.get("Pages") or [] try: page = pages[int(cfg.get("ActivePage") or 0)] except (IndexError, TypeError, ValueError): page = pages[0] if pages else {} out = [] for scheme in (page.get("Schemes") or []): keycode = int(scheme.get("KeyCode") or 0) if keycode <= 0: continue out.append({ "name": scheme.get("Name") or "", "character": scheme.get("CharacterName") or "", "keycode": keycode, "modifiers": int(scheme.get("Modifiers") or 0), }) return {"page": page.get("Name") or "", "schemes": out} def _loadout_characters() -> tuple[int, bytes, str]: """Character list for the loadout page. The admin endpoint without a selector is bound to the logged-in account and can be empty when the game client is still at the login screen. The launcher's local DB API already exposes the full local character list, so reuse it here and keep the loadout page usable before entering the game. """ try: req = request.Request("http://127.0.0.1:19527/api/characters", headers={"Accept": "application/json"}) with OPENER.open(req, timeout=8) as resp: data = json.loads(resp.read().decode("utf-8")) chars = data.get("data") if isinstance(data, dict) else None out = [] for c in chars or []: if not isinstance(c, dict): continue out.append({ "character_id": str(c.get("character_id") or ""), "name": c.get("name") or "", "level": c.get("level") or 0, "online": bool(c.get("online")), }) return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8" except (OSError, ValueError) as exc: return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc}, ensure_ascii=False).encode(), "application/json; charset=utf-8" # --------------------------------------------------------------------- HTTP class Handler(BaseHTTPRequestHandler): server_version = "90CN-GMWeb/1.0" def log_message(self, fmt, *args): # 静默,避免刷屏 pass def _send(self, code: int, payload: bytes, ctype: str) -> None: self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self.end_headers() try: self.wfile.write(payload) except OSError: pass def _static(self, path: str) -> None: rel = "index.html" if path in ("/", "") else path.lstrip("/") target = (HERE / rel).resolve() if HERE not in target.parents and target != HERE: self._send(403, b"forbidden", "text/plain") return if not target.is_file(): self._send(404, b"not found", "text/plain") return self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream")) def do_GET(self) -> None: route = self.path.split("?", 1)[0] if route == "/api/loadout/config": payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode() self._send(200, payload, "application/json; charset=utf-8") return if route == "/api/loadout/characters": code, payload, ctype = _loadout_characters() self._send(code, payload, ctype) return if route == "/api/loadout/hotkeys": payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode() self._send(200, payload, "application/json; charset=utf-8") return if self.path.startswith("/api/"): code, data, ctype = proxy("GET", self.path[4:], None) self._send(code, data, ctype) return self._static(route) def do_POST(self) -> None: route = self.path.split("?", 1)[0] length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else b"{}" if route == "/api/loadout/config": try: data = json.loads(body.decode("utf-8")) except ValueError: self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json") return code, payload, ctype = _write_loadout_config(data) self._send(code, payload, ctype) return if not self.path.startswith("/api/"): self._send(404, b"not found", "text/plain") return code, data, ctype = proxy("POST", self.path[4:], body) self._send(code, data, ctype) def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description="90CN GM 页本地托管 + 接口代理") ap.add_argument("--port", type=int, default=18112) ap.add_argument("--host", default="127.0.0.1") args = ap.parse_args(argv) listen, token = discover_admin() CONFIG["admin"], CONFIG["token"] = listen, token if not token: print("警告:没找到 adminToken,接口会 401。可用 DNF90_ADMIN_TOKEN 环境变量指定。", file=sys.stderr) srv = ThreadingHTTPServer((args.host, args.port), Handler) print("GM 页: http://%s:%d/" % (args.host, args.port)) print("代理到: http://%s/api/ -> http://%s/" % (args.host + ":" + str(args.port), listen)) try: srv.serve_forever() except KeyboardInterrupt: pass return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))