Owner-approved: serve on the Tailscale address only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
95 lines
3.7 KiB
Python
95 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Serve the local VoxelForge web viewer and a folder of converted .glb meshes."""
|
|
|
|
import argparse
|
|
import json
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
|
|
|
|
def make_handler(mesh_dir):
|
|
root = mesh_dir.resolve()
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=str(APP_DIR), **kwargs)
|
|
|
|
def do_GET(self):
|
|
route = urlsplit(self.path).path
|
|
if route == "/api/meshes":
|
|
meshes = []
|
|
for path in sorted(root.glob("*.glb"), key=lambda item: item.name.casefold()):
|
|
try:
|
|
resolved = path.resolve(strict=True)
|
|
if resolved.parent == root and resolved.is_file():
|
|
meshes.append({"name": path.name, "bytes": resolved.stat().st_size})
|
|
except OSError:
|
|
continue
|
|
body = json.dumps(meshes, separators=(",", ":")).encode("utf-8")
|
|
self.send_response(200)
|
|
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()
|
|
self.wfile.write(body)
|
|
return
|
|
|
|
if route.startswith("/meshes/"):
|
|
name = unquote(route[len("/meshes/"):])
|
|
if not name or Path(name).name != name or not name.endswith(".glb"):
|
|
self.send_error(404)
|
|
return
|
|
path = root / name
|
|
try:
|
|
resolved = path.resolve(strict=True)
|
|
if resolved.parent != root or not resolved.is_file():
|
|
raise OSError("not a regular mesh file")
|
|
size = resolved.stat().st_size
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "model/gltf-binary")
|
|
self.send_header("Content-Length", str(size))
|
|
self.send_header("Cache-Control", "public, max-age=300")
|
|
self.end_headers()
|
|
with resolved.open("rb") as mesh:
|
|
self.copyfile(mesh, self.wfile)
|
|
except OSError:
|
|
self.send_error(404)
|
|
return
|
|
super().do_GET()
|
|
|
|
def log_message(self, fmt, *args):
|
|
print(f"[{self.log_date_time_string()}] {self.address_string()} {fmt % args}")
|
|
|
|
return Handler
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--mesh-dir", type=Path, default=APP_DIR / "meshes",
|
|
help="folder containing converted .glb files")
|
|
parser.add_argument("--port", type=int, default=8000)
|
|
parser.add_argument("--host", default="127.0.0.1",
|
|
help="address to bind; default loopback only (e.g. a Tailscale address)")
|
|
args = parser.parse_args()
|
|
if not 0 < args.port < 65536:
|
|
parser.error("--port must be between 1 and 65535")
|
|
args.mesh_dir.mkdir(parents=True, exist_ok=True)
|
|
server = ThreadingHTTPServer((args.host, args.port), make_handler(args.mesh_dir))
|
|
server.daemon_threads = True
|
|
print(f"VoxelForge viewer: http://{args.host}:{args.port}/")
|
|
print(f"Mesh folder: {args.mesh_dir.resolve()}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("Stopping viewer server")
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|