File size: 6,612 Bytes
f425b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Public, read-only conversation explorer. No API tokens or inference runtime."""
import gzip
import json
import os
import threading
import time
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit

from source_store import BASE, SourceStore
from tree_decoder import DecodeError, VERSION

STATIC = {"/": ("site/index.html", "text/html; charset=utf-8"),
          "/style.css": ("site/style.css", "text/css; charset=utf-8"),
          "/app.js": ("site/app.js", "text/javascript; charset=utf-8"),
          "/tree_decoder.py": ("tree_decoder.py", "text/x-python; charset=utf-8"),
          "/AAC_cli_original.py": ("public_decoder/AAC_cli_original.py", "text/x-python; charset=utf-8"),
          "/favicon.svg": ("site/favicon.svg", "image/svg+xml")}


class Server(ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True

    def __init__(self, address, store=None):
        self.store = store or SourceStore()
        self.slots = threading.BoundedSemaphore(12)
        super().__init__(address, Handler)

    def process_request(self, request, client_address):
        if not self.slots.acquire(blocking=False):
            try:
                request.sendall(b"HTTP/1.1 503 Service Unavailable\r\nRetry-After: 3\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
            finally:
                self.shutdown_request(request)
            return
        try:
            super().process_request(request, client_address)
        except Exception:
            self.slots.release()
            raise

    def process_request_thread(self, request, client_address):
        try:
            super().process_request_thread(request, client_address)
        finally:
            self.slots.release()


class Handler(BaseHTTPRequestHandler):
    server_version = "SMLTree/" + VERSION

    def setup(self):
        super().setup()
        self.connection.settimeout(65)

    def log_message(self, fmt, *args):
        # Public tree paths only; no visitor conversation content is submitted or stored.
        print("http", self.command, urlsplit(self.path).path, args[1] if len(args) > 1 else "", flush=True)

    def send(self, status, raw, content_type="application/json; charset=utf-8", cache="no-store", download=None):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Cache-Control", cache)
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; "
                         "img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'")
        if download:
            self.send_header("Content-Disposition", 'attachment; filename="' + download + '"')
        if len(raw) > 2048 and "gzip" in self.headers.get("Accept-Encoding", ""):
            raw = gzip.compress(raw, compresslevel=4)
            self.send_header("Content-Encoding", "gzip")
            self.send_header("Vary", "Accept-Encoding")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(raw)

    def json(self, status, value, **kwargs):
        self.send(status, json.dumps(value, ensure_ascii=False).encode(), **kwargs)

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        started = time.perf_counter()
        try:
            if len(self.path) > 8192:
                raise DecodeError("Request is too long.")
            url = urlsplit(self.path)
            if url.path in STATIC:
                file, mime = STATIC[url.path]
                download = file.split("/")[-1] if file.endswith(".py") else None
                return self.send(200, (BASE / file).read_bytes(), mime, "public, max-age=300", download)
            if url.path == "/health":
                return self.json(200, {"ok": True, "decoder_version": VERSION,
                                       "catalog_files": len(self.server.store.rows)})
            if url.path == "/api/catalog":
                return self.json(200, self.server.store.catalog(), cache="public, max-age=300")
            if url.path not in ("/api/document", "/api/trace", "/api/export"):
                return self.json(404, {"error": "Not found."})
            query = parse_qs(url.query, keep_blank_values=True, max_num_fields=8)
            if any(len(v) != 1 for v in query.values()):
                raise DecodeError("Query parameters must appear once.")
            ident = query.get("id", [""])[0]
            document, source = self.server.store.get(ident)
            if url.path == "/api/document":
                summaries = document.summaries()
                return self.json(200, {"source": source, "conversations": summaries,
                                       "status": "supported" if summaries else "not-a-supported-conversation",
                                       "load_ms": round((time.perf_counter() - started) * 1000, 2)})
            conversation = int(query.get("conversation", ["0"])[0])
            raw_path = query.get("path", [""])[0]
            choices = [int(i) for i in raw_path.split(",")] if raw_path else []
            if len(choices) > 128:
                raise DecodeError("Web paths are limited to 128 selections; the Python decoder has no path cap.")
            result = {**document.trace(conversation, choices), "source": source,
                      "decode_ms": round((time.perf_counter() - started) * 1000, 2)}
            return self.json(200, result, download="sml-conversation-trace.json" if url.path == "/api/export" else None)
        except KeyError:
            self.json(404, {"error": "Unknown catalog source."})
        except (DecodeError, ValueError, RecursionError) as error:
            self.json(422, {"error": str(error)})
        except (urllib.error.URLError, TimeoutError, OSError):
            try:
                self.json(502, {"error": "The public source could not be loaded. Retry in a moment; your path is preserved."})
            except OSError:
                pass


if __name__ == "__main__":
    server = Server((os.environ.get("HOST", "0.0.0.0"), int(os.environ.get("PORT", "7860"))))
    print("SML Tree Explorer listening on port", server.server_port, flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()