Files
90CNServer/gmweb/serve.py
T

330 lines
13 KiB
Python

#!/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/<path> -> http://127.0.0.1:18111/<path>
"""
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:]))