230 lines
8.3 KiB
Python
230 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Natural Memory v2 — 站点服务端
|
||
|
||
纯标准库实现,无第三方依赖(目标环境 Python 3.8+)。
|
||
|
||
默认监听 127.0.0.1:7443(仅本机可达,供 Cloudflare Tunnel 等反向代理使用)。
|
||
如需对外直接监听,显式指定 --host 0.0.0.0。
|
||
|
||
用法:
|
||
python server.py
|
||
python server.py --host 0.0.0.0 --port 7443
|
||
set NM_PORT=8443 && python server.py
|
||
|
||
设计约定(安全默认):
|
||
只放行 index.html 与 assets/ 下白名单扩展名的文件,其余全部 404。
|
||
server.py、启动脚本、说明文档等一律不通过 HTTP 暴露;目录列表关闭。
|
||
任何包含 .. / . / 反斜杠 / NUL 的路径直接拒绝。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import unquote, urlparse
|
||
|
||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||
SITE_VERSION = "v2"
|
||
STARTED = time.time()
|
||
|
||
# Python 3.8 的 mimetypes 不认 woff2,需要显式登记
|
||
mimetypes.add_type("font/woff2", ".woff2")
|
||
mimetypes.add_type("font/woff", ".woff")
|
||
mimetypes.add_type("application/javascript", ".js")
|
||
mimetypes.add_type("image/svg+xml", ".svg")
|
||
|
||
ALLOWED_EXT = {
|
||
".html", ".css", ".js", ".mjs", ".json",
|
||
".woff2", ".woff", ".ttf",
|
||
".svg", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".ico",
|
||
}
|
||
ALLOWED_ROOT_FILES = {"index.html", "favicon.ico", "robots.txt"}
|
||
|
||
# 生成出来的分页(build.py 的 PAGES 列表)。支持 /division 与 /division.html 两种写法。
|
||
PAGE_SLUGS = {
|
||
"index", "division", "mechanism", "results", "nm21", "examples",
|
||
"ledger", "cost", "capacity", "limits", "roadmap", "reproduce",
|
||
}
|
||
|
||
LONG_CACHE = (".woff2", ".woff", ".ttf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".svg", ".ico")
|
||
NO_CACHE = (".html", ".css", ".js", ".json")
|
||
|
||
|
||
def human_uptime(seconds: float) -> str:
|
||
seconds = int(seconds)
|
||
d, rem = divmod(seconds, 86400)
|
||
h, rem = divmod(rem, 3600)
|
||
m, s = divmod(rem, 60)
|
||
if d:
|
||
return "%dd %dh" % (d, h)
|
||
if h:
|
||
return "%dh %dm" % (h, m)
|
||
if m:
|
||
return "%dm %ds" % (m, s)
|
||
return "%ds" % s
|
||
|
||
|
||
class Handler(SimpleHTTPRequestHandler):
|
||
server_version = "NaturalMemorySite/" + SITE_VERSION
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
# ---------------- 路径安全 ----------------
|
||
@staticmethod
|
||
def _safe_rel(path: str):
|
||
"""把请求路径解析为站点内的相对路径;不合规返回 None。"""
|
||
raw = unquote(path or "")
|
||
if "\x00" in raw or "\\" in raw:
|
||
return None
|
||
segs = raw.split("/")
|
||
if any(s in (".", "..") for s in segs):
|
||
return None
|
||
segs = [s for s in segs if s]
|
||
if not segs:
|
||
return "index.html"
|
||
if len(segs) == 1:
|
||
name = segs[0]
|
||
if name in ALLOWED_ROOT_FILES:
|
||
return name
|
||
# 干净 URL:/division → division.html
|
||
if name in PAGE_SLUGS:
|
||
return "index.html" if name == "index" else "%s.html" % name
|
||
if name.endswith(".html") and name[:-5] in PAGE_SLUGS:
|
||
return name
|
||
return None
|
||
if segs[0] == "assets" and os.path.splitext(segs[-1])[1].lower() in ALLOWED_EXT:
|
||
return "/".join(segs)
|
||
return None
|
||
|
||
# ---------------- 运行时读数 ----------------
|
||
def _status_payload(self) -> bytes:
|
||
host, port = self.server.server_address[0], self.server.server_address[1]
|
||
payload = {
|
||
"site": "Natural Memory " + SITE_VERSION,
|
||
"note": "以下为本站 Web 服务进程的读数,不是模型运行时的指标。",
|
||
"host": getattr(self.server, "display_host", host),
|
||
"bind": "%s:%d" % (host, port),
|
||
"python": "%d.%d.%d" % sys.version_info[:3],
|
||
"uptime": human_uptime(time.time() - STARTED),
|
||
"uptime_seconds": round(time.time() - STARTED, 1),
|
||
"requests": getattr(self.server, "request_count", 0),
|
||
"started": datetime.fromtimestamp(STARTED).astimezone().isoformat(timespec="seconds"),
|
||
"now": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||
}
|
||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
|
||
def _send_json(self, body: bytes, status: int = 200) -> None:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
if self.command != "HEAD":
|
||
self.wfile.write(body)
|
||
|
||
# ---------------- 路由 ----------------
|
||
def _dispatch(self, with_body: bool) -> None:
|
||
self.server.request_count = getattr(self.server, "request_count", 0) + 1
|
||
path = urlparse(self.path).path
|
||
|
||
if path.rstrip("/") == "/api/status":
|
||
self._send_json(self._status_payload())
|
||
return
|
||
|
||
if path == "/healthz":
|
||
self._send_json(b'{"ok":true}')
|
||
return
|
||
|
||
rel = self._safe_rel(path)
|
||
if rel is None:
|
||
# 不区分「不存在」与「不允许」,避免泄露文件是否存在
|
||
self.send_error(404, "Not Found")
|
||
return
|
||
|
||
self.path = "/" + rel
|
||
if with_body:
|
||
SimpleHTTPRequestHandler.do_GET(self)
|
||
else:
|
||
SimpleHTTPRequestHandler.do_HEAD(self)
|
||
|
||
def do_GET(self): # noqa: N802
|
||
self._dispatch(True)
|
||
|
||
def do_HEAD(self): # noqa: N802
|
||
self._dispatch(False)
|
||
|
||
# ---------------- 缓存与安全响应头 ----------------
|
||
def end_headers(self):
|
||
path = urlparse(self.path).path.lower()
|
||
if path.endswith(".html"):
|
||
# 页面本体永不缓存:模板一变立刻生效
|
||
self.send_header("Cache-Control", "no-store")
|
||
elif path.endswith(LONG_CACHE):
|
||
# 静态资源走构建指纹(?v=hash),可长期不可变缓存
|
||
self.send_header("Cache-Control", "public, max-age=604800, immutable")
|
||
elif path.endswith(NO_CACHE):
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("Referrer-Policy", "same-origin")
|
||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||
super().end_headers()
|
||
|
||
# ---------------- 关闭目录列表 ----------------
|
||
def list_directory(self, path):
|
||
self.send_error(404, "Not Found")
|
||
return None
|
||
|
||
def log_message(self, fmt, *args):
|
||
sys.stderr.write("[%s] %s %s\n" % (
|
||
datetime.now().strftime("%H:%M:%S"), self.address_string(), fmt % args,
|
||
))
|
||
|
||
|
||
class Server(ThreadingHTTPServer):
|
||
"""ThreadingHTTPServer 已含 ThreadingMixIn,这里只补充运行时读数字段。"""
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
request_count = 0
|
||
display_host = "127.0.0.1"
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="Natural Memory v2 site server")
|
||
ap.add_argument("--host", default=os.environ.get("NM_HOST", "127.0.0.1"),
|
||
help="监听地址(默认 127.0.0.1,仅本机可达)")
|
||
ap.add_argument("--port", type=int, default=int(os.environ.get("NM_PORT", "7443")),
|
||
help="监听端口(默认 7443)")
|
||
args = ap.parse_args()
|
||
|
||
if not os.path.isfile(os.path.join(ROOT, "index.html")):
|
||
sys.stderr.write("index.html 不在 %s,请从站点根目录启动。\n" % ROOT)
|
||
return 2
|
||
|
||
httpd = Server((args.host, args.port), lambda *a, **kw: Handler(*a, directory=ROOT, **kw))
|
||
httpd.display_host = args.host
|
||
|
||
print("Natural Memory %s — site server" % SITE_VERSION)
|
||
print(" 根目录 : %s" % ROOT)
|
||
print(" 监听 : http://%s:%d/" % (args.host, args.port))
|
||
print(" 读数 : http://%s:%d/api/status" % (args.host, args.port))
|
||
if args.host in ("127.0.0.1", "localhost"):
|
||
print(" 提示 : 当前仅本机可达;需要对外请用反向代理,或显式 --host 0.0.0.0")
|
||
print(" Ctrl+C 退出")
|
||
try:
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\n已停止。")
|
||
finally:
|
||
httpd.server_close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|