Files
VoxelForge/Standalone/Tools/webviewer/server.py
T
Fr0zkaandClaude Opus 5.5 7c12b96ed1 tools: webviewer --host option (default stays 127.0.0.1)
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
2026-10-29 00:01:13 +01:00

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()