Initial commit: 自然记忆站:12 页纯标准库 Python 静态站点生成器,含真实评测台账(108 条)与全站客户端检索
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user