1664693d3d
Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""FastAPI 中控许可中间件与 /license 页。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html as html_lib
|
||
import os
|
||
from pathlib import Path
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||
|
||
from lib.license.license_lib import (
|
||
get_device_id,
|
||
get_license_status,
|
||
is_license_valid,
|
||
redeem_code,
|
||
validate_license,
|
||
)
|
||
|
||
_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "license.html"
|
||
_POST_LICENSE_TARGET = "/login"
|
||
|
||
|
||
def _license_public_path(path: str) -> bool:
|
||
"""未授权时仍可访问的路径(授权页 / 接口 / 静态资源)。"""
|
||
if path in (
|
||
"/license",
|
||
"/api/license/status",
|
||
"/api/license/redeem",
|
||
"/api/license/validate",
|
||
"/health",
|
||
):
|
||
return True
|
||
if path.startswith("/assets/") or path.startswith("/static/"):
|
||
return True
|
||
if path.startswith("/favicon"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _license_manage_requested(request: Request) -> bool:
|
||
"""已授权时默认禁止进入 /license;续费/换机用 ?renew=1。"""
|
||
q = request.query_params
|
||
return (q.get("renew") or q.get("manage") or "").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
|
||
|
||
def _locally_licensed() -> bool:
|
||
return bool(get_license_status(skip_remote=True).get("valid"))
|
||
|
||
|
||
def _leave_license_response(target: str = _POST_LICENSE_TARGET) -> Response:
|
||
safe = html_lib.escape(target, quote=True)
|
||
body = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta http-equiv="refresh" content="0;url={safe}">
|
||
<title>已授权</title>
|
||
<style>
|
||
body {{
|
||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||
background: #0a0a10; color: #e8e8f0; font-family: sans-serif;
|
||
}}
|
||
a {{ color: #7ec8ff; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<p>已激活,正在进入系统… <a href="{safe}">点击进入</a></p>
|
||
<script>location.replace({target!r});</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
return Response(
|
||
content=body,
|
||
status_code=302,
|
||
headers={
|
||
"Location": target,
|
||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||
"Content-Type": "text/html; charset=utf-8",
|
||
},
|
||
)
|
||
|
||
|
||
def install_license_middleware(app: FastAPI) -> None:
|
||
@app.get("/health")
|
||
async def _license_health():
|
||
st = get_license_status(skip_remote=True)
|
||
return {"ok": True, "license_valid": bool(st.get("valid")), "service": "manual_trading_hub"}
|
||
|
||
@app.get("/api/license/status")
|
||
async def _license_status_api():
|
||
return get_license_status()
|
||
|
||
@app.post("/api/license/redeem")
|
||
async def _license_redeem_api(request: Request):
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
code = str(data.get("code") or "").strip()
|
||
ckey = str(data.get("client_api_key") or "").strip()
|
||
return redeem_code(code, client_api_key=ckey or None)
|
||
|
||
@app.post("/api/license/validate")
|
||
async def _license_validate_api():
|
||
return validate_license(force=True)
|
||
|
||
@app.api_route("/license", methods=["GET", "POST"])
|
||
async def _license_page(request: Request):
|
||
# 已授权:默认不可再进授权页(续费/换机:/license?renew=1)
|
||
if _locally_licensed() and request.method == "GET" and not _license_manage_requested(request):
|
||
return _leave_license_response()
|
||
|
||
msg = ""
|
||
err = ""
|
||
if request.method == "POST":
|
||
form = await request.form()
|
||
code = str(form.get("code") or "").strip()
|
||
ckey = str(form.get("client_api_key") or "").strip()
|
||
result = redeem_code(code, client_api_key=ckey or None)
|
||
if result.get("ok"):
|
||
return _leave_license_response()
|
||
err = result.get("message") or "激活失败"
|
||
status = get_license_status(skip_remote=True)
|
||
html = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||
filled = (
|
||
html.replace("{{ device_id }}", get_device_id())
|
||
.replace("{{ api_url }}", str(status.get("api_url") or ""))
|
||
.replace("{{ wechat }}", "dekun03")
|
||
.replace("{{ message }}", msg)
|
||
.replace("{{ error }}", err)
|
||
.replace("{{ status_message }}", str(status.get("message") or ""))
|
||
.replace("{{ expires_at }}", str(status.get("expires_at") or "—"))
|
||
.replace("{{ plan }}", str(status.get("plan") or "—"))
|
||
.replace("{{ valid_text }}", "已授权" if status.get("valid") else "未授权")
|
||
)
|
||
if "{%" in filled or "{{" in filled:
|
||
from jinja2 import Template
|
||
|
||
filled = Template(html).render(
|
||
device_id=get_device_id(),
|
||
status=status,
|
||
message=msg,
|
||
error=err,
|
||
api_url=status.get("api_url") or "",
|
||
wechat="dekun03",
|
||
)
|
||
return HTMLResponse(filled)
|
||
|
||
@app.middleware("http")
|
||
async def _license_http_middleware(request: Request, call_next):
|
||
if os.getenv("LICENSE_DISABLED", "").strip().lower() in ("1", "true", "yes", "on"):
|
||
return await call_next(request)
|
||
path = request.url.path or "/"
|
||
if _license_public_path(path):
|
||
return await call_next(request)
|
||
if is_license_valid():
|
||
return await call_next(request)
|
||
if path.startswith("/api/"):
|
||
return JSONResponse(
|
||
{"ok": False, "error": "license_required", "message": "请先激活许可"},
|
||
status_code=403,
|
||
)
|
||
return RedirectResponse(url="/license", status_code=302)
|