forked from Jason-Vaughan/TangleBrain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
182 lines (149 loc) · 6.92 KB
/
Copy pathserver.py
File metadata and controls
182 lines (149 loc) · 6.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Knob GUI HTTP server — stdlib :mod:`http.server`, localhost-only, zero new deps.
The handler is a thin shell over :func:`dispatch`, a pure ``(method, path, body) -> (status,
content_type, body)`` function holding all routing so it can be tested without a socket. The page
itself is the packaged single-file ``static/index.html``.
Launched via the ``tanglebrain-gui`` console script. Binds ``127.0.0.1`` only — not configurable:
the panel runs prompts (spending real backend quota) and reads the roster, so it must never be
network-exposed.
"""
from __future__ import annotations
import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
from tanglebrain.gui.views import (
DEFAULT_PORT,
run_prompt,
save_pricing_view,
save_roster_view,
view_pricing,
view_roster,
view_settings,
view_stats,
)
_JSON = "application/json; charset=utf-8"
_HTML = "text/html; charset=utf-8"
_PNG = "image/png"
def _index_html() -> bytes:
"""Read the packaged single-file panel (``tanglebrain/gui/static/index.html``)."""
return (resources.files("tanglebrain.gui") / "static" / "index.html").read_bytes()
def _logo_png() -> bytes:
"""Read the packaged panel logo (``tanglebrain/gui/static/logo.png``)."""
return (resources.files("tanglebrain.gui") / "static" / "logo.png").read_bytes()
def _json(status: int, obj: object) -> tuple[int, str, bytes]:
"""Serialize ``obj`` as a JSON HTTP response triple."""
return status, _JSON, json.dumps(obj).encode("utf-8")
def dispatch(
method: str, path: str, body: bytes = b"", content_type: str = "application/json"
) -> tuple[int, str, bytes]:
"""Route one request to a view and return ``(status, content_type, body)``.
Pure and side-effect-light (only the views touch config/log/subprocess), so tests call it
directly with no socket. The query string, if any, is ignored.
POST requires ``Content-Type: application/json``. The panel's own ``fetch`` calls all send
it; the check closes the browser "simple request" hole — a cross-origin ``fetch`` from a
malicious page can POST ``text/plain`` to localhost without a CORS preflight, and ``/api/run``
spends real backend quota, so a non-JSON content type must never reach a view (mirrors the
serve endpoint's guard).
Args:
method: HTTP method (``GET``/``POST``).
path: Request path (may include a ``?query``).
body: Raw request body bytes (for ``POST``).
content_type: The request's ``Content-Type`` header value (POST only; defaults to JSON
so socket-free tests needn't supply it).
Returns:
``(status_code, content_type, body_bytes)``.
"""
path = path.split("?", 1)[0]
if method == "GET":
if path in ("/", "/index.html"):
return 200, _HTML, _index_html()
if path == "/logo.png":
return 200, _PNG, _logo_png()
view = {
"/api/roster": view_roster,
"/api/pricing": view_pricing,
"/api/stats": view_stats,
"/api/settings": view_settings,
}.get(path)
if view is not None:
try:
return _json(200, view())
except Exception as exc: # a read view failed (e.g. malformed roster) — clean JSON, not a 500 traceback
return _json(500, {"error": str(exc)})
return _json(404, {"error": "not found"})
if method == "POST":
action = {
"/api/run": run_prompt,
"/api/pricing": save_pricing_view,
"/api/roster": save_roster_view,
}.get(path)
if action is not None:
if not (content_type or "").lower().strip().startswith("application/json"):
return _json(
415, {"ok": False, "error": "Content-Type must be application/json"}
)
try:
payload = json.loads(body.decode("utf-8")) if body else {}
except (ValueError, UnicodeDecodeError):
return _json(400, {"ok": False, "error": "invalid JSON body"})
if not isinstance(payload, dict):
return _json(400, {"ok": False, "error": "body must be a JSON object"})
result = action(payload)
return _json(200 if result.get("ok") else 400, result)
return _json(404, {"error": "not found"})
return _json(405, {"error": "method not allowed"})
class Handler(BaseHTTPRequestHandler):
"""Thin HTTP handler delegating all routing to :func:`dispatch`."""
def do_GET(self) -> None: # noqa: N802 (stdlib naming)
"""Handle a GET by dispatching and writing the response."""
self._respond(*dispatch("GET", self.path))
def do_POST(self) -> None: # noqa: N802 (stdlib naming)
"""Handle a POST by reading the body, dispatching, and writing the response."""
try:
# Clamp negatives (rfile.read(-1) would block on the socket) and map a non-numeric
# header to a clean JSON 400 instead of a traceback + dropped connection.
length = max(0, int(self.headers.get("Content-Length", 0) or 0))
except ValueError:
self._respond(*_json(400, {"ok": False, "error": "invalid Content-Length header"}))
return
body = self.rfile.read(length) if length else b""
self._respond(*dispatch("POST", self.path, body, self.headers.get("Content-Type", "")))
def _respond(self, status: int, content_type: str, body: bytes) -> None:
"""Write a complete HTTP response."""
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
"""Silence the default per-request stderr logging."""
def main(argv: list[str] | None = None) -> int:
"""Console entry point: serve the knob panel until interrupted.
Args:
argv: Optional argument list (defaults to ``sys.argv[1:]``).
Returns:
Process exit code (``0``).
"""
parser = argparse.ArgumentParser(
prog="tanglebrain-gui",
description="Serve the TangleBrain knob panel (read-only) on localhost.",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_PORT,
help=f"Port to bind (default {DEFAULT_PORT}).",
)
args = parser.parse_args(argv)
# Loopback only, not configurable: the panel is unauthenticated and runs prompts / reads the
# roster, so it must never be reachable off the machine.
host = "127.0.0.1"
server = ThreadingHTTPServer((host, args.port), Handler)
print(f"TangleBrain knob panel: http://{host}:{args.port}/ (Ctrl-C to stop)")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopping…")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())