zettcode-gateway 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/gateway/__init__.py +22 -0
- zettcode/gateway/app.py +454 -0
- zettcode/gateway/backend.py +368 -0
- zettcode/gateway/chat.py +142 -0
- zettcode/gateway/cli.py +495 -0
- zettcode/gateway/py.typed +0 -0
- zettcode/gateway/runfile.py +145 -0
- zettcode/gateway/service.py +1219 -0
- zettcode/gateway/settings.py +62 -0
- zettcode/gateway/static/app.css +1434 -0
- zettcode/gateway/static/app.js +3 -0
- zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
- zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
- zettcode/gateway/static/icon.png +0 -0
- zettcode/gateway/static/index.html +23 -0
- zettcode/gateway/static/logo.svg +205 -0
- zettcode/gateway/static/manifest.webmanifest +11 -0
- zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
- zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
- zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The web gateway: a browser front end for the background agents on this machine.
|
|
2
|
+
|
|
3
|
+
zettcode gateway # then open the address it prints
|
|
4
|
+
|
|
5
|
+
A page lists the daemons (``zettcode daemon``) and the other sessions that are running, starts and stops daemons, and
|
|
6
|
+
lets a person talk to any of them as to a contact: what is typed is sent as a message from this gateway, and what the
|
|
7
|
+
session says comes back into the same conversation. The gateway is not an agent. It is one more session of the kind a
|
|
8
|
+
terminal is, with a person at it and no model of its own to run, and it reaches the agent through ``zettcode.sdk``
|
|
9
|
+
alone: starting and stopping daemons, who is registered, sending, and reading a stored conversation.
|
|
10
|
+
|
|
11
|
+
* :mod:`.chat` -- a stored conversation as chat items (no I/O);
|
|
12
|
+
* :mod:`.backend` -- what the gateway needs from the SDK, as an interface, and the one implementation that uses it;
|
|
13
|
+
* :mod:`.service` -- the gateway itself: threads, sending, daemons, unread marks and the events a page listens to;
|
|
14
|
+
* :mod:`.app` -- the web application: sign-in, the JSON endpoints, the event socket and the page;
|
|
15
|
+
* :mod:`.cli` -- the ``zettcode gateway`` command.
|
|
16
|
+
|
|
17
|
+
``docs/internal/gateway.md`` has the design and the rules the tests pin.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__all__ = ["app", "backend", "chat", "cli", "service", "settings"]
|
zettcode/gateway/app.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""The gateway as a web application: sign-in, the JSON endpoints, the event socket, and the page itself.
|
|
2
|
+
|
|
3
|
+
Nothing here decides anything about sessions (that is :mod:`.service`); this is what any web service has: who is let
|
|
4
|
+
in, how a body is read, how a refusal is answered, how a WebSocket is read and written. Every route but the page, its
|
|
5
|
+
files and the sign-in asks for a signed-in browser (a cookie) or the token as a bearer.
|
|
6
|
+
|
|
7
|
+
The gateway can start agents that run commands, so a browser is trusted only after it has shown the token, the cookie
|
|
8
|
+
is not readable by the page's scripts and is not sent across sites, and a request that carries an ``Origin`` has to
|
|
9
|
+
carry the gateway's own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from collections import deque
|
|
24
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
25
|
+
from contextlib import asynccontextmanager
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
from starlette.applications import Starlette
|
|
31
|
+
from starlette.middleware import Middleware
|
|
32
|
+
from starlette.requests import HTTPConnection, Request
|
|
33
|
+
from starlette.responses import FileResponse, JSONResponse, PlainTextResponse, Response
|
|
34
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
35
|
+
from starlette.staticfiles import StaticFiles
|
|
36
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
37
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
38
|
+
|
|
39
|
+
from .backend import Backend, GatewayStartError, SdkBackend
|
|
40
|
+
from .runfile import clear as clear_runfile
|
|
41
|
+
from .runfile import write as write_runfile
|
|
42
|
+
from .service import Gateway, GatewayError
|
|
43
|
+
from .settings import Settings
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("zettcode.gateway")
|
|
46
|
+
|
|
47
|
+
#: The folder that holds the page, its script and its style.
|
|
48
|
+
STATIC = Path(__file__).parent / "static"
|
|
49
|
+
|
|
50
|
+
#: What a checkout that has not built the page says instead of it.
|
|
51
|
+
NOT_BUILT = (
|
|
52
|
+
"The gateway's page has not been built in this checkout. Run `make gateway-ui` (needs Node 20+), or install the "
|
|
53
|
+
"published package, which carries it. The API is up."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
#: The cookie a signed-in browser carries, and how long it stays good.
|
|
57
|
+
COOKIE = "zg_session"
|
|
58
|
+
COOKIE_SECONDS = 30 * 24 * 3600
|
|
59
|
+
|
|
60
|
+
#: The most a request body may be, in bytes. A message is at most 32,000 characters.
|
|
61
|
+
MAX_BODY = 256 * 1024
|
|
62
|
+
|
|
63
|
+
#: The most a message that sends pictures may be: what the broker takes (``MAX_MESSAGE_BODY``, which a test keeps equal).
|
|
64
|
+
MAX_SEND_BODY = (1 << 20) + (8 << 20) // 3 * 4 + 4
|
|
65
|
+
|
|
66
|
+
#: Wrong tokens tolerated in a window before sign-in is refused for a while.
|
|
67
|
+
LOGIN_FAILURES = 8
|
|
68
|
+
LOGIN_WINDOW = 600.0
|
|
69
|
+
|
|
70
|
+
#: What a WebSocket is closed with when it is not signed in.
|
|
71
|
+
UNAUTHORIZED = 4401
|
|
72
|
+
|
|
73
|
+
#: The names a browser on this machine reaches it by.
|
|
74
|
+
LOOPBACK_NAMES = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
75
|
+
|
|
76
|
+
#: What the page may load: its own files, and inline styles (the math typesetter writes them) but never inline script, so a message with
|
|
77
|
+
#: markup in it has nowhere to run. Images from other sites are not loaded: a message cannot make the page call out.
|
|
78
|
+
POLICY = (
|
|
79
|
+
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
|
|
80
|
+
"object-src 'none'; base-uri 'none'; "
|
|
81
|
+
"form-action 'self'; frame-ancestors 'none'"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Auth:
|
|
86
|
+
"""Who is signed in: the token, the cookies issued for it, and how many wrong tokens have been tried."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, token: str, clock: Callable[[], float] = time.monotonic) -> None:
|
|
89
|
+
self.token = token
|
|
90
|
+
self.clock = clock
|
|
91
|
+
self._sessions: dict[str, float] = {}
|
|
92
|
+
self._failures: deque[float] = deque()
|
|
93
|
+
|
|
94
|
+
def token_ok(self, given: str) -> bool:
|
|
95
|
+
"""Return whether ``given`` is the token, compared in constant time."""
|
|
96
|
+
return bool(self.token) and hmac.compare_digest(given.encode(), self.token.encode())
|
|
97
|
+
|
|
98
|
+
def locked_out(self) -> bool:
|
|
99
|
+
"""Return whether too many wrong tokens were tried lately."""
|
|
100
|
+
now = self.clock()
|
|
101
|
+
while self._failures and now - self._failures[0] > LOGIN_WINDOW:
|
|
102
|
+
self._failures.popleft()
|
|
103
|
+
return len(self._failures) >= LOGIN_FAILURES
|
|
104
|
+
|
|
105
|
+
def failed(self) -> None:
|
|
106
|
+
"""Count a wrong token."""
|
|
107
|
+
self._failures.append(self.clock())
|
|
108
|
+
|
|
109
|
+
def issue(self) -> str:
|
|
110
|
+
"""Return a new cookie value for a browser that showed the token."""
|
|
111
|
+
value = secrets.token_urlsafe(32)
|
|
112
|
+
self._sessions[value] = self.clock() + COOKIE_SECONDS
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
def revoke(self, value: str | None) -> None:
|
|
116
|
+
"""Forget a cookie, so it no longer signs anybody in."""
|
|
117
|
+
if value:
|
|
118
|
+
self._sessions.pop(value, None)
|
|
119
|
+
|
|
120
|
+
def cookie_ok(self, value: str | None) -> bool:
|
|
121
|
+
"""Return whether a cookie was issued and has not run out."""
|
|
122
|
+
if not value:
|
|
123
|
+
return False
|
|
124
|
+
expires = self._sessions.get(value)
|
|
125
|
+
if expires is None:
|
|
126
|
+
return False
|
|
127
|
+
if expires < self.clock():
|
|
128
|
+
self._sessions.pop(value, None)
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def open(self) -> bool:
|
|
134
|
+
"""Return whether there is no token, so nothing is asked of a browser but that it is on this machine."""
|
|
135
|
+
return not self.token
|
|
136
|
+
|
|
137
|
+
def allowed(self, connection: HTTPConnection) -> bool:
|
|
138
|
+
"""Return whether a request or socket is signed in, by cookie or by bearer token.
|
|
139
|
+
|
|
140
|
+
With no token every request is let in, but only one addressed to this machine by name: a page on another site
|
|
141
|
+
can make a browser send requests to a local port, and a name that resolves to it (DNS rebinding) would
|
|
142
|
+
otherwise be trusted as the page itself.
|
|
143
|
+
"""
|
|
144
|
+
if self.open:
|
|
145
|
+
return urlsplit("//" + connection.headers.get("host", "")).hostname in LOOPBACK_NAMES
|
|
146
|
+
if self.cookie_ok(connection.cookies.get(COOKIE)):
|
|
147
|
+
return True
|
|
148
|
+
scheme, _, given = connection.headers.get("authorization", "").partition(" ")
|
|
149
|
+
return scheme.lower() == "bearer" and self.token_ok(given.strip())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def same_origin(connection: HTTPConnection, *, required: bool = False) -> bool:
|
|
153
|
+
"""Return whether a request did not come from another site: it names this host in ``Origin``.
|
|
154
|
+
|
|
155
|
+
A request with no ``Origin`` is let through unless ``required``: a browser sends one on every write and every socket,
|
|
156
|
+
and with no token (nothing but this check stands between a page on another site and a gateway that runs commands) a
|
|
157
|
+
program that writes to the gateway says where it is from too.
|
|
158
|
+
"""
|
|
159
|
+
origin = connection.headers.get("origin")
|
|
160
|
+
if not origin:
|
|
161
|
+
return not required
|
|
162
|
+
return urlsplit(origin).netloc == connection.headers.get("host", "")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Headers:
|
|
166
|
+
"""ASGI middleware that puts the response headers every answer of the gateway carries on it."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
169
|
+
self.app = app
|
|
170
|
+
|
|
171
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
172
|
+
if scope["type"] != "http":
|
|
173
|
+
await self.app(scope, receive, send)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
async def wrapped(message: Any) -> None:
|
|
177
|
+
if message["type"] == "http.response.start":
|
|
178
|
+
headers = message.setdefault("headers", [])
|
|
179
|
+
headers.extend(
|
|
180
|
+
(name.encode(), value.encode())
|
|
181
|
+
for name, value in (
|
|
182
|
+
("content-security-policy", POLICY),
|
|
183
|
+
("x-content-type-options", "nosniff"),
|
|
184
|
+
("referrer-policy", "no-referrer"),
|
|
185
|
+
("x-frame-options", "DENY"),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
await send(message)
|
|
189
|
+
|
|
190
|
+
await self.app(scope, receive, wrapped)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Files(StaticFiles):
|
|
194
|
+
"""The page's files, always revalidated: the page and its script change together and must not be mixed."""
|
|
195
|
+
|
|
196
|
+
def file_response(self, *args: Any, **kwargs: Any) -> Response:
|
|
197
|
+
response = super().file_response(*args, **kwargs)
|
|
198
|
+
response.headers["cache-control"] = "no-cache"
|
|
199
|
+
return response
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
Handler = Callable[[Request], Awaitable[Any]]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def create_app(settings: Settings, backend: Backend | None = None, *, static: Path = STATIC) -> Starlette:
|
|
206
|
+
"""Return the application for these settings; a test gives a backend of its own, and the folder the page is in."""
|
|
207
|
+
auth = Auth(settings.token)
|
|
208
|
+
gateway = Gateway(backend if backend is not None else SdkBackend(settings.workspace, settings.directory), settings)
|
|
209
|
+
|
|
210
|
+
@asynccontextmanager
|
|
211
|
+
async def lifespan(_: Starlette) -> AsyncIterator[None]:
|
|
212
|
+
try:
|
|
213
|
+
await gateway.start()
|
|
214
|
+
except Exception as error:
|
|
215
|
+
logger.exception("start.failed", extra={"host": settings.host, "port": settings.port})
|
|
216
|
+
if isinstance(error, GatewayStartError):
|
|
217
|
+
sys.stderr.write(f"zettcode gateway: {error}\n")
|
|
218
|
+
raise
|
|
219
|
+
# Written only once the gateway is a session: a second one that lost the session would otherwise replace
|
|
220
|
+
# the record of the one that kept it. The record is what `zettcode gateway stop|restart` finds.
|
|
221
|
+
write_runfile(settings)
|
|
222
|
+
logger.info("start", extra={"host": settings.host, "port": settings.port})
|
|
223
|
+
try:
|
|
224
|
+
yield
|
|
225
|
+
finally:
|
|
226
|
+
clear_runfile(settings.directory, pid=os.getpid())
|
|
227
|
+
await gateway.close()
|
|
228
|
+
logger.info("stop", extra={"host": settings.host, "port": settings.port})
|
|
229
|
+
|
|
230
|
+
# -- plumbing ---------------------------------------------------------------------------------
|
|
231
|
+
def api(handler: Handler, *, public: bool = False) -> Callable[[Request], Awaitable[Response]]:
|
|
232
|
+
"""Wrap an endpoint: check who is asking, run it, and answer a refusal or a fault in the one shape."""
|
|
233
|
+
|
|
234
|
+
async def route(request: Request) -> Response:
|
|
235
|
+
try:
|
|
236
|
+
if not public and not auth.allowed(request):
|
|
237
|
+
raise GatewayError(401, "unauthorized", "Sign in first")
|
|
238
|
+
if request.method not in ("GET", "HEAD") and not same_origin(request, required=auth.open):
|
|
239
|
+
raise GatewayError(403, "forbidden", "This request came from another site")
|
|
240
|
+
result = await handler(request)
|
|
241
|
+
return result if isinstance(result, Response) else JSONResponse(result)
|
|
242
|
+
except GatewayError as error:
|
|
243
|
+
headers = {"retry-after": str(error.extra["retry_after"])} if "retry_after" in error.extra else None
|
|
244
|
+
return JSONResponse(error.to_dict(), error.status, headers=headers)
|
|
245
|
+
except Exception as error: # noqa: BLE001 - one request failing must not end the server
|
|
246
|
+
logger.exception("request.failed", extra={"method": request.method, "route": request.url.path})
|
|
247
|
+
return JSONResponse(
|
|
248
|
+
{"error": {"code": "internal", "message": f"The request failed ({type(error).__name__})"}}, 500
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return route
|
|
252
|
+
|
|
253
|
+
async def body(request: Request, limit: int = MAX_BODY) -> dict[str, Any]:
|
|
254
|
+
declared = request.headers.get("content-length", "")
|
|
255
|
+
if declared.isdigit() and int(declared) > limit:
|
|
256
|
+
raise GatewayError(413, "too_long", "The request is too large")
|
|
257
|
+
data = b""
|
|
258
|
+
async for chunk in request.stream():
|
|
259
|
+
data += chunk
|
|
260
|
+
if len(data) > limit:
|
|
261
|
+
raise GatewayError(413, "too_long", "The request is too large")
|
|
262
|
+
try:
|
|
263
|
+
parsed = json.loads(data or b"{}")
|
|
264
|
+
except ValueError:
|
|
265
|
+
raise GatewayError(400, "invalid", "The body is not JSON") from None
|
|
266
|
+
if not isinstance(parsed, dict):
|
|
267
|
+
raise GatewayError(400, "invalid", "The body is a JSON object")
|
|
268
|
+
return parsed
|
|
269
|
+
|
|
270
|
+
# -- sign-in ----------------------------------------------------------------------------------
|
|
271
|
+
async def session(request: Request) -> Any:
|
|
272
|
+
return {"authenticated": auth.allowed(request), "open": auth.open}
|
|
273
|
+
|
|
274
|
+
async def login(request: Request) -> Any:
|
|
275
|
+
if auth.locked_out():
|
|
276
|
+
raise GatewayError(429, "rate", "Too many wrong tokens; wait a few minutes", retry_after=60)
|
|
277
|
+
given = (await body(request)).get("token")
|
|
278
|
+
if not isinstance(given, str) or not auth.token_ok(given.strip()):
|
|
279
|
+
auth.failed()
|
|
280
|
+
await asyncio.sleep(0.4)
|
|
281
|
+
raise GatewayError(401, "unauthorized", "That is not the token")
|
|
282
|
+
response = JSONResponse({"authenticated": True})
|
|
283
|
+
response.set_cookie(
|
|
284
|
+
COOKIE,
|
|
285
|
+
auth.issue(),
|
|
286
|
+
max_age=COOKIE_SECONDS,
|
|
287
|
+
httponly=True,
|
|
288
|
+
samesite="strict",
|
|
289
|
+
secure=request.url.scheme == "https",
|
|
290
|
+
path="/",
|
|
291
|
+
)
|
|
292
|
+
return response
|
|
293
|
+
|
|
294
|
+
async def logout(request: Request) -> Any:
|
|
295
|
+
auth.revoke(request.cookies.get(COOKIE))
|
|
296
|
+
response = JSONResponse({"authenticated": False})
|
|
297
|
+
response.delete_cookie(COOKIE, path="/")
|
|
298
|
+
return response
|
|
299
|
+
|
|
300
|
+
# -- the endpoints ----------------------------------------------------------------------------
|
|
301
|
+
async def status(_: Request) -> Any:
|
|
302
|
+
return gateway.status()
|
|
303
|
+
|
|
304
|
+
async def config(_: Request) -> Any:
|
|
305
|
+
return gateway.config()
|
|
306
|
+
|
|
307
|
+
async def add_model(request: Request) -> Any:
|
|
308
|
+
return await gateway.add_model(await body(request))
|
|
309
|
+
|
|
310
|
+
async def change_model(request: Request) -> Any:
|
|
311
|
+
return await gateway.change_model(request.path_params["name"], await body(request))
|
|
312
|
+
|
|
313
|
+
async def remove_model(request: Request) -> Any:
|
|
314
|
+
return await gateway.remove_model(request.path_params["name"])
|
|
315
|
+
|
|
316
|
+
async def dirs(request: Request) -> Any:
|
|
317
|
+
return gateway.browse(request.query_params.get("path", ""))
|
|
318
|
+
|
|
319
|
+
async def threads(_: Request) -> Any:
|
|
320
|
+
return {"threads": gateway.threads(), "status": gateway.status()}
|
|
321
|
+
|
|
322
|
+
async def graph(_: Request) -> Any:
|
|
323
|
+
return await gateway.graph()
|
|
324
|
+
|
|
325
|
+
async def older(request: Request) -> Any:
|
|
326
|
+
query = request.query_params
|
|
327
|
+
return await gateway.older(query.get("before"), query.get("limit"), query.get("group"))
|
|
328
|
+
|
|
329
|
+
async def send_group(request: Request) -> Any:
|
|
330
|
+
given = await body(request)
|
|
331
|
+
return await gateway.send_group(request.path_params["name"], str(given.get("text") or ""))
|
|
332
|
+
|
|
333
|
+
async def timeline(request: Request) -> Any:
|
|
334
|
+
query = request.query_params
|
|
335
|
+
return await gateway.timeline(query.get("since"), query.get("until"), query.get("limit"))
|
|
336
|
+
|
|
337
|
+
async def thread(request: Request) -> Any:
|
|
338
|
+
everything = request.query_params.get("all") in ("1", "true")
|
|
339
|
+
return await gateway.thread(request.path_params["id"], everything=everything)
|
|
340
|
+
|
|
341
|
+
async def read(request: Request) -> Any:
|
|
342
|
+
await gateway.read(request.path_params["id"])
|
|
343
|
+
return {"ok": True}
|
|
344
|
+
|
|
345
|
+
async def recheck(request: Request) -> Any:
|
|
346
|
+
return {"thread": await gateway.recheck(request.path_params["id"])}
|
|
347
|
+
|
|
348
|
+
async def send(request: Request) -> Any:
|
|
349
|
+
data = await body(request, MAX_SEND_BODY)
|
|
350
|
+
text = data.get("text")
|
|
351
|
+
reply = data.get("reply")
|
|
352
|
+
if not isinstance(text, str) or (reply is not None and not isinstance(reply, bool)):
|
|
353
|
+
raise GatewayError(400, "invalid", "text is a string and reply a boolean")
|
|
354
|
+
sent = await gateway.send(request.path_params["id"], text, reply=reply, images=data.get("images"))
|
|
355
|
+
return sent | {"status": gateway.status()}
|
|
356
|
+
|
|
357
|
+
async def picture(request: Request) -> Any:
|
|
358
|
+
data, media_type = await gateway.picture(request.path_params["id"], request.path_params["picture"])
|
|
359
|
+
# Named by its own hash, so it never changes: a browser keeps it. Only a type a model takes is served (the service
|
|
360
|
+
# checks it as it serves, whatever the stored file says), and the app never lets a browser guess another.
|
|
361
|
+
return Response(
|
|
362
|
+
data,
|
|
363
|
+
media_type=media_type,
|
|
364
|
+
headers={"cache-control": "private, max-age=31536000, immutable"},
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def spawn(request: Request) -> Any:
|
|
368
|
+
return await gateway.spawn(await body(request))
|
|
369
|
+
|
|
370
|
+
async def stop(request: Request) -> Any:
|
|
371
|
+
given = await body(request)
|
|
372
|
+
return await gateway.stop(request.path_params["id"], force=given.get("force") is True)
|
|
373
|
+
|
|
374
|
+
async def delete(request: Request) -> Any:
|
|
375
|
+
return await gateway.delete(request.path_params["id"])
|
|
376
|
+
|
|
377
|
+
async def log(request: Request) -> Any:
|
|
378
|
+
return await gateway.log(request.path_params["id"])
|
|
379
|
+
|
|
380
|
+
async def index(_: Request) -> Response:
|
|
381
|
+
page = static / "index.html"
|
|
382
|
+
if not page.is_file():
|
|
383
|
+
# The page is built from clients/gateway when a distribution is made and is not kept in the repository, so a
|
|
384
|
+
# checkout that has not built it has an API and no page: say so, rather than answer 404 to the first request.
|
|
385
|
+
return PlainTextResponse(NOT_BUILT, status_code=503)
|
|
386
|
+
return FileResponse(page, headers={"cache-control": "no-store"})
|
|
387
|
+
|
|
388
|
+
async def manifest(_: Request) -> Response:
|
|
389
|
+
return FileResponse(
|
|
390
|
+
static / "manifest.webmanifest",
|
|
391
|
+
media_type="application/manifest+json",
|
|
392
|
+
headers={"cache-control": "no-cache"},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# -- the event socket -------------------------------------------------------------------------
|
|
396
|
+
async def events(websocket: WebSocket) -> None:
|
|
397
|
+
if not auth.allowed(websocket) or not same_origin(websocket, required=auth.open):
|
|
398
|
+
await websocket.close(UNAUTHORIZED)
|
|
399
|
+
return
|
|
400
|
+
await websocket.accept()
|
|
401
|
+
queue = gateway.subscribe()
|
|
402
|
+
|
|
403
|
+
async def pump() -> None:
|
|
404
|
+
with contextlib.suppress(Exception):
|
|
405
|
+
while True:
|
|
406
|
+
event = await queue.get()
|
|
407
|
+
if event.get("type") == "close":
|
|
408
|
+
await websocket.close(1013) # "try again later": the page reconnects and reads everything again
|
|
409
|
+
return
|
|
410
|
+
await websocket.send_text(json.dumps(event, separators=(",", ":")))
|
|
411
|
+
|
|
412
|
+
writer = asyncio.ensure_future(pump())
|
|
413
|
+
try:
|
|
414
|
+
while True:
|
|
415
|
+
await websocket.receive_text()
|
|
416
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
417
|
+
pass
|
|
418
|
+
finally:
|
|
419
|
+
writer.cancel()
|
|
420
|
+
await asyncio.gather(writer, return_exceptions=True)
|
|
421
|
+
gateway.unsubscribe(queue)
|
|
422
|
+
|
|
423
|
+
routes = [
|
|
424
|
+
Route("/", index),
|
|
425
|
+
Route("/manifest.webmanifest", manifest),
|
|
426
|
+
Route("/api/session", api(session, public=True)),
|
|
427
|
+
Route("/api/login", api(login, public=True), methods=["POST"]),
|
|
428
|
+
Route("/api/logout", api(logout, public=True), methods=["POST"]),
|
|
429
|
+
Route("/api/status", api(status)),
|
|
430
|
+
Route("/api/config", api(config)),
|
|
431
|
+
Route("/api/models", api(add_model), methods=["POST"]),
|
|
432
|
+
Route("/api/models/{name:path}", api(change_model), methods=["PATCH"]),
|
|
433
|
+
Route("/api/models/{name:path}", api(remove_model), methods=["DELETE"]),
|
|
434
|
+
Route("/api/dirs", api(dirs)),
|
|
435
|
+
Route("/api/graph", api(graph)),
|
|
436
|
+
Route("/api/graph/timeline", api(timeline)),
|
|
437
|
+
Route("/api/graph/older", api(older)),
|
|
438
|
+
Route("/api/threads", api(threads)),
|
|
439
|
+
Route("/api/threads/{id}", api(thread)),
|
|
440
|
+
Route("/api/threads/{id}/read", api(read), methods=["POST"]),
|
|
441
|
+
Route("/api/threads/{id}/recheck", api(recheck), methods=["POST"]),
|
|
442
|
+
Route("/api/threads/{id}/send", api(send), methods=["POST"]),
|
|
443
|
+
Route("/api/threads/{id}/pictures/{picture}", api(picture)),
|
|
444
|
+
Route("/api/daemons", api(spawn), methods=["POST"]),
|
|
445
|
+
Route("/api/daemons/{id}", api(delete), methods=["DELETE"]),
|
|
446
|
+
Route("/api/daemons/{id}/stop", api(stop), methods=["POST"]),
|
|
447
|
+
Route("/api/daemons/{id}/log", api(log)),
|
|
448
|
+
WebSocketRoute("/api/events", events),
|
|
449
|
+
Mount("/static", Files(directory=static, check_dir=False), name="static"),
|
|
450
|
+
]
|
|
451
|
+
app = Starlette(routes=routes, lifespan=lifespan, middleware=[Middleware(Headers)])
|
|
452
|
+
app.state.gateway = gateway
|
|
453
|
+
app.state.auth = auth
|
|
454
|
+
return app
|