zettcode-gateway 0.4.0__tar.gz → 0.4.2__tar.gz
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-0.4.0 → zettcode_gateway-0.4.2}/.gitignore +5 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/PKG-INFO +2 -2
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/pyproject.toml +1 -1
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/app.py +69 -5
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/cli.py +58 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/runfile.py +5 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/settings.py +19 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/app.css +58 -0
- zettcode_gateway-0.4.2/src/zettcode/gateway/static/app.js +3 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/index.html +3 -0
- zettcode_gateway-0.4.0/src/zettcode/gateway/static/app.js +0 -3
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/__init__.py +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/backend.py +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/chat.py +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/py.typed +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/service.py +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/chunks/render-G6G3TSB3.js +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/icon.png +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/logo.svg +0 -0
- {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/manifest.webmanifest +0 -0
|
@@ -35,3 +35,8 @@ docs/internal/palettes.html
|
|
|
35
35
|
|
|
36
36
|
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
37
|
packages/order.txt
|
|
38
|
+
|
|
39
|
+
# The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
|
|
40
|
+
# installers under release/.
|
|
41
|
+
clients/desktop/renderer/
|
|
42
|
+
clients/desktop/release/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: zettcode-gateway
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: The web gateway of ZettCode: the chat page and the graph over a running server
|
|
5
5
|
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
6
|
Author: Chang-LeHung
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.15
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
|
-
Requires-Dist: starlette<2,>=0.
|
|
18
|
+
Requires-Dist: starlette<2,>=0.51
|
|
19
19
|
Requires-Dist: uvicorn<1,>=0.35
|
|
20
20
|
Requires-Dist: zettcode-engine
|
|
21
21
|
Requires-Dist: zettcode-sdk
|
|
@@ -12,6 +12,8 @@ carry the gateway's own.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
15
17
|
import contextlib
|
|
16
18
|
import hmac
|
|
17
19
|
import json
|
|
@@ -29,6 +31,7 @@ from urllib.parse import urlsplit
|
|
|
29
31
|
|
|
30
32
|
from starlette.applications import Starlette
|
|
31
33
|
from starlette.middleware import Middleware
|
|
34
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
32
35
|
from starlette.requests import HTTPConnection, Request
|
|
33
36
|
from starlette.responses import FileResponse, JSONResponse, PlainTextResponse, Response
|
|
34
37
|
from starlette.routing import Mount, Route, WebSocketRoute
|
|
@@ -70,6 +73,12 @@ LOGIN_WINDOW = 600.0
|
|
|
70
73
|
#: What a WebSocket is closed with when it is not signed in.
|
|
71
74
|
UNAUTHORIZED = 4401
|
|
72
75
|
|
|
76
|
+
#: The subprotocol a native client asks for, and the prefix under which it offers its token. A browser cannot put an
|
|
77
|
+
#: ``Authorization`` header on a WebSocket, so the token goes beside the subprotocol, base64url-encoded: a protocol
|
|
78
|
+
#: name is a token and base64url is made of token characters.
|
|
79
|
+
WS_PROTOCOL = "zettcode"
|
|
80
|
+
WS_TOKEN_PREFIX = "zettcode.token."
|
|
81
|
+
|
|
73
82
|
#: The names a browser on this machine reaches it by.
|
|
74
83
|
LOOPBACK_NAMES = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
75
84
|
|
|
@@ -149,19 +158,43 @@ class Auth:
|
|
|
149
158
|
return scheme.lower() == "bearer" and self.token_ok(given.strip())
|
|
150
159
|
|
|
151
160
|
|
|
152
|
-
def same_origin(connection: HTTPConnection, *, required: bool = False) -> bool:
|
|
161
|
+
def same_origin(connection: HTTPConnection, *, required: bool = False, allowed: tuple[str, ...] = ()) -> bool:
|
|
153
162
|
"""Return whether a request did not come from another site: it names this host in ``Origin``.
|
|
154
163
|
|
|
155
164
|
A request with no ``Origin`` is let through unless ``required``: a browser sends one on every write and every socket,
|
|
156
165
|
and with no token (nothing but this check stands between a page on another site and a gateway that runs commands) a
|
|
157
166
|
program that writes to the gateway says where it is from too.
|
|
167
|
+
|
|
168
|
+
``allowed`` names the origins of a native client (see :attr:`Settings.allowed_origins`); they are let through as
|
|
169
|
+
the gateway's own, and every request from them still has to carry the token.
|
|
158
170
|
"""
|
|
159
171
|
origin = connection.headers.get("origin")
|
|
160
172
|
if not origin:
|
|
161
173
|
return not required
|
|
174
|
+
if origin in allowed:
|
|
175
|
+
return True
|
|
162
176
|
return urlsplit(origin).netloc == connection.headers.get("host", "")
|
|
163
177
|
|
|
164
178
|
|
|
179
|
+
def offered(connection: HTTPConnection) -> list[str]:
|
|
180
|
+
"""Return the WebSocket subprotocols the client offered, trimmed."""
|
|
181
|
+
header = connection.headers.get("sec-websocket-protocol", "")
|
|
182
|
+
return [item.strip() for item in header.split(",") if item.strip()]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def websocket_token(connection: HTTPConnection) -> str:
|
|
186
|
+
"""Return the token a native client offered as a subprotocol, or ``""`` when it offered none it can be read from."""
|
|
187
|
+
for protocol in offered(connection):
|
|
188
|
+
if not protocol.startswith(WS_TOKEN_PREFIX):
|
|
189
|
+
continue
|
|
190
|
+
encoded = protocol[len(WS_TOKEN_PREFIX) :]
|
|
191
|
+
try:
|
|
192
|
+
return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
|
|
193
|
+
except (binascii.Error, UnicodeDecodeError, ValueError):
|
|
194
|
+
return ""
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
|
|
165
198
|
class Headers:
|
|
166
199
|
"""ASGI middleware that puts the response headers every answer of the gateway carries on it."""
|
|
167
200
|
|
|
@@ -205,6 +238,7 @@ Handler = Callable[[Request], Awaitable[Any]]
|
|
|
205
238
|
def create_app(settings: Settings, backend: Backend | None = None, *, static: Path = STATIC) -> Starlette:
|
|
206
239
|
"""Return the application for these settings; a test gives a backend of its own, and the folder the page is in."""
|
|
207
240
|
auth = Auth(settings.token)
|
|
241
|
+
allowed = settings.allowed_origins
|
|
208
242
|
gateway = Gateway(backend if backend is not None else SdkBackend(settings.workspace, settings.directory), settings)
|
|
209
243
|
|
|
210
244
|
@asynccontextmanager
|
|
@@ -235,7 +269,9 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
|
|
|
235
269
|
try:
|
|
236
270
|
if not public and not auth.allowed(request):
|
|
237
271
|
raise GatewayError(401, "unauthorized", "Sign in first")
|
|
238
|
-
if request.method not in ("GET", "HEAD") and not same_origin(
|
|
272
|
+
if request.method not in ("GET", "HEAD") and not same_origin(
|
|
273
|
+
request, required=auth.open, allowed=allowed
|
|
274
|
+
):
|
|
239
275
|
raise GatewayError(403, "forbidden", "This request came from another site")
|
|
240
276
|
result = await handler(request)
|
|
241
277
|
return result if isinstance(result, Response) else JSONResponse(result)
|
|
@@ -394,10 +430,19 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
|
|
|
394
430
|
|
|
395
431
|
# -- the event socket -------------------------------------------------------------------------
|
|
396
432
|
async def events(websocket: WebSocket) -> None:
|
|
397
|
-
|
|
433
|
+
protocols = offered(websocket)
|
|
434
|
+
subprotocol = WS_PROTOCOL if WS_PROTOCOL in protocols else None
|
|
435
|
+
if not (auth.allowed(websocket) or auth.token_ok(websocket_token(websocket))) or not same_origin(
|
|
436
|
+
websocket, required=auth.open, allowed=allowed
|
|
437
|
+
):
|
|
438
|
+
# Accept first: a close before the handshake is an HTTP refusal, which a browser reports as 1006, and the
|
|
439
|
+
# page could not tell a refused token from a dropped network. Echo the offered subprotocol as well: a
|
|
440
|
+
# browser that negotiated one refuses a handshake that comes back without it.
|
|
441
|
+
await websocket.accept(subprotocol=subprotocol)
|
|
398
442
|
await websocket.close(UNAUTHORIZED)
|
|
399
443
|
return
|
|
400
|
-
|
|
444
|
+
# A native client offers ``zettcode`` beside its token; echo it, or the browser would refuse the socket.
|
|
445
|
+
await websocket.accept(subprotocol=subprotocol)
|
|
401
446
|
queue = gateway.subscribe()
|
|
402
447
|
|
|
403
448
|
async def pump() -> None:
|
|
@@ -441,6 +486,7 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
|
|
|
441
486
|
Route("/api/threads/{id}/recheck", api(recheck), methods=["POST"]),
|
|
442
487
|
Route("/api/threads/{id}/send", api(send), methods=["POST"]),
|
|
443
488
|
Route("/api/threads/{id}/pictures/{picture}", api(picture)),
|
|
489
|
+
Route("/api/groups/{name}/send", api(send_group), methods=["POST"]),
|
|
444
490
|
Route("/api/daemons", api(spawn), methods=["POST"]),
|
|
445
491
|
Route("/api/daemons/{id}", api(delete), methods=["DELETE"]),
|
|
446
492
|
Route("/api/daemons/{id}/stop", api(stop), methods=["POST"]),
|
|
@@ -448,7 +494,25 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
|
|
|
448
494
|
WebSocketRoute("/api/events", events),
|
|
449
495
|
Mount("/static", Files(directory=static, check_dir=False), name="static"),
|
|
450
496
|
]
|
|
451
|
-
app = Starlette(
|
|
497
|
+
app = Starlette(
|
|
498
|
+
routes=routes,
|
|
499
|
+
lifespan=lifespan,
|
|
500
|
+
middleware=[
|
|
501
|
+
# Outermost, so a refusal from the routes below still carries the headers a native client's browser
|
|
502
|
+
# needs to read it, and a preflight is answered before it can be mistaken for a write that needs the token.
|
|
503
|
+
Middleware(
|
|
504
|
+
CORSMiddleware,
|
|
505
|
+
allow_origins=list(allowed),
|
|
506
|
+
allow_methods=["GET", "HEAD", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
507
|
+
allow_headers=["authorization", "content-type"],
|
|
508
|
+
# The desktop page, or an operator's --client-origin page, can be reached over a private network;
|
|
509
|
+
# the allowlist is what keeps a public page out, and the token is still required.
|
|
510
|
+
allow_private_network=True,
|
|
511
|
+
max_age=600,
|
|
512
|
+
),
|
|
513
|
+
Middleware(Headers),
|
|
514
|
+
],
|
|
515
|
+
)
|
|
452
516
|
app.state.gateway = gateway
|
|
453
517
|
app.state.auth = auth
|
|
454
518
|
return app
|
|
@@ -27,6 +27,7 @@ import sys
|
|
|
27
27
|
import time
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from typing import Any, Literal
|
|
30
|
+
from urllib.parse import urlsplit
|
|
30
31
|
|
|
31
32
|
from zettcode import logging_setup
|
|
32
33
|
from zettcode.processes import command_line, is_module_command, still_there
|
|
@@ -56,6 +57,40 @@ EXIT_OK = 0
|
|
|
56
57
|
EXIT_FAILED = 1
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def origin(value: str) -> str:
|
|
61
|
+
"""Return ``value`` as an origin, or stop: a scheme and a host, and nothing after a port.
|
|
62
|
+
|
|
63
|
+
A browser sends ``Origin`` as ``scheme://host[:port]`` and never with a trailing slash, so a flag that accepts one
|
|
64
|
+
would never match anything. Only schemes a page or a native shell can run under are accepted: ``ws://`` is a
|
|
65
|
+
socket, not an origin a browser ever sends.
|
|
66
|
+
"""
|
|
67
|
+
text = value.strip().rstrip("/")
|
|
68
|
+
parts = urlsplit(text)
|
|
69
|
+
try:
|
|
70
|
+
port = parts.port
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise argparse.ArgumentTypeError(f"not an origin: {value!r} (the port is out of range)") from None
|
|
73
|
+
scheme = parts.scheme.lower()
|
|
74
|
+
host = parts.hostname
|
|
75
|
+
if (
|
|
76
|
+
scheme not in ("http", "https", "app")
|
|
77
|
+
or not host
|
|
78
|
+
or parts.path
|
|
79
|
+
or parts.query
|
|
80
|
+
or parts.fragment
|
|
81
|
+
or "@" in parts.netloc
|
|
82
|
+
or parts.netloc.endswith(":")
|
|
83
|
+
or (port is not None and not 0 < port < 65536)
|
|
84
|
+
):
|
|
85
|
+
raise argparse.ArgumentTypeError(f"not an origin: {value!r} (write scheme://host[:port])")
|
|
86
|
+
# A browser omits a scheme's default port; strip it here or the flag would never match.
|
|
87
|
+
if port is None or (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
|
88
|
+
authority = f"[{host}]" if ":" in host else host
|
|
89
|
+
else:
|
|
90
|
+
authority = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
|
|
91
|
+
return f"{scheme}://{authority}"
|
|
92
|
+
|
|
93
|
+
|
|
59
94
|
def build_parser() -> argparse.ArgumentParser:
|
|
60
95
|
"""Return the parser: the action, where to listen, who may sign in, and where agents may be started."""
|
|
61
96
|
parser = argparse.ArgumentParser(
|
|
@@ -112,6 +147,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
112
147
|
default=argparse.SUPPRESS,
|
|
113
148
|
help="let the page start daemons with the full shell level again, undoing a recorded --no-full",
|
|
114
149
|
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--client-origin",
|
|
152
|
+
metavar="ORIGIN",
|
|
153
|
+
action="append",
|
|
154
|
+
type=origin,
|
|
155
|
+
default=argparse.SUPPRESS,
|
|
156
|
+
help=(
|
|
157
|
+
"let a page served from this origin call the gateway, on top of the built-in native client origins "
|
|
158
|
+
"(repeatable; needs a token). An origin is scheme://host[:port], without a path"
|
|
159
|
+
),
|
|
160
|
+
)
|
|
115
161
|
parser.add_argument(
|
|
116
162
|
"--directory",
|
|
117
163
|
metavar="DIR",
|
|
@@ -171,6 +217,11 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
|
|
|
171
217
|
else:
|
|
172
218
|
directory = base.directory if base is not None else default_directory()
|
|
173
219
|
log_level = getattr(args, "log_level", None) or (base.log_level if base is not None else "info")
|
|
220
|
+
origins_given = getattr(args, "client_origin", None)
|
|
221
|
+
if origins_given is None:
|
|
222
|
+
client_origins = base.client_origins if base is not None else ()
|
|
223
|
+
else:
|
|
224
|
+
client_origins = tuple(dict.fromkeys(origins_given))
|
|
174
225
|
|
|
175
226
|
if not 0 < port < 65536:
|
|
176
227
|
raise SystemExit(f"zettcode gateway: --port must be between 1 and 65535, not {port}")
|
|
@@ -182,6 +233,10 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
|
|
|
182
233
|
f"zettcode gateway: listening on {host} needs a token (--token or ${TOKEN_VARIABLE}): "
|
|
183
234
|
"without one anybody on the network could run commands as you"
|
|
184
235
|
)
|
|
236
|
+
if client_origins and not token:
|
|
237
|
+
raise SystemExit(
|
|
238
|
+
"zettcode gateway: --client-origin needs a token: without one another site could drive the gateway"
|
|
239
|
+
)
|
|
185
240
|
return Settings(
|
|
186
241
|
token=token,
|
|
187
242
|
host=host,
|
|
@@ -190,6 +245,7 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
|
|
|
190
245
|
allow_full=allow_full,
|
|
191
246
|
directory=directory,
|
|
192
247
|
log_level=log_level,
|
|
248
|
+
client_origins=client_origins,
|
|
193
249
|
)
|
|
194
250
|
|
|
195
251
|
|
|
@@ -351,6 +407,8 @@ def spawn(settings: Settings) -> subprocess.Popen[bytes]:
|
|
|
351
407
|
arguments.append("--no-full")
|
|
352
408
|
for root in settings.roots:
|
|
353
409
|
arguments.extend(("--root", str(root)))
|
|
410
|
+
for source in settings.client_origins:
|
|
411
|
+
arguments.extend(("--client-origin", source))
|
|
354
412
|
environment = {**os.environ, TOKEN_VARIABLE: settings.token}
|
|
355
413
|
options: dict[str, Any] = {
|
|
356
414
|
"stdin": subprocess.DEVNULL,
|
|
@@ -66,6 +66,7 @@ def write(settings: Settings) -> None:
|
|
|
66
66
|
"roots": [str(root) for root in settings.roots],
|
|
67
67
|
"allow_full": settings.allow_full,
|
|
68
68
|
"log_level": settings.log_level,
|
|
69
|
+
"client_origins": list(settings.client_origins),
|
|
69
70
|
}
|
|
70
71
|
descriptor, temporary = tempfile.mkstemp(dir=settings.directory, prefix=".run-", suffix=".tmp")
|
|
71
72
|
try:
|
|
@@ -96,6 +97,7 @@ def read(directory: Path) -> Running | None:
|
|
|
96
97
|
pid, port = raw.get("pid"), raw.get("port")
|
|
97
98
|
host, token, log_level = raw.get("host"), raw.get("token"), raw.get("log_level")
|
|
98
99
|
roots, allow_full = raw.get("roots"), raw.get("allow_full")
|
|
100
|
+
client_origins = raw.get("client_origins", [])
|
|
99
101
|
if not (_whole(pid) and pid > 0 and _whole(port) and 0 < port < 65536):
|
|
100
102
|
return None
|
|
101
103
|
if not (isinstance(host, str) and isinstance(token, str) and isinstance(log_level, str)):
|
|
@@ -104,6 +106,8 @@ def read(directory: Path) -> Running | None:
|
|
|
104
106
|
return None
|
|
105
107
|
if not isinstance(roots, list) or not all(isinstance(root, str) for root in roots):
|
|
106
108
|
return None
|
|
109
|
+
if not isinstance(client_origins, list) or not all(isinstance(item, str) for item in client_origins):
|
|
110
|
+
return None
|
|
107
111
|
settings = Settings(
|
|
108
112
|
token=token,
|
|
109
113
|
host=host,
|
|
@@ -114,6 +118,7 @@ def read(directory: Path) -> Running | None:
|
|
|
114
118
|
# folder.
|
|
115
119
|
directory=directory,
|
|
116
120
|
log_level=log_level,
|
|
121
|
+
client_origins=tuple(client_origins),
|
|
117
122
|
)
|
|
118
123
|
return Running(pid, settings)
|
|
119
124
|
|
|
@@ -13,6 +13,11 @@ DIRECTORY_VARIABLE = "ZETTCODE_GATEWAY_DIR"
|
|
|
13
13
|
DEFAULT_HOST = "127.0.0.1"
|
|
14
14
|
DEFAULT_PORT = 8765
|
|
15
15
|
|
|
16
|
+
#: The origin the desktop client runs under: Electron's ``app://`` page. A gateway with a token lets it in; with no
|
|
17
|
+
#: token it is ignored, because then the origin check is the only thing between another site and a gateway that runs
|
|
18
|
+
#: commands. The Android app loads the gateway's own page at the gateway's origin and needs no entry here.
|
|
19
|
+
APP_ORIGINS = ("app://-",)
|
|
20
|
+
|
|
16
21
|
|
|
17
22
|
def default_directory() -> Path:
|
|
18
23
|
"""Return the gateway's own folder: ``$ZETTCODE_GATEWAY_DIR``, or ``~/.zettcode/gateway``."""
|
|
@@ -35,6 +40,8 @@ class Settings:
|
|
|
35
40
|
command line (``--no-full`` turns it off), never from the page.
|
|
36
41
|
directory: The gateway's own folder: its workspace, the id of its session, and what the page remembers.
|
|
37
42
|
log_level: uvicorn's log level.
|
|
43
|
+
client_origins: Extra origins a native client or a page of its own is served from, on top of
|
|
44
|
+
:data:`APP_ORIGINS`. Ignored without a token.
|
|
38
45
|
"""
|
|
39
46
|
|
|
40
47
|
token: str = ""
|
|
@@ -44,12 +51,24 @@ class Settings:
|
|
|
44
51
|
allow_full: bool = False
|
|
45
52
|
directory: Path = field(default_factory=default_directory)
|
|
46
53
|
log_level: str = "info"
|
|
54
|
+
client_origins: tuple[str, ...] = ()
|
|
47
55
|
|
|
48
56
|
@property
|
|
49
57
|
def open(self) -> bool:
|
|
50
58
|
"""Return whether anybody who can reach the gateway may use it, with no token."""
|
|
51
59
|
return not self.token
|
|
52
60
|
|
|
61
|
+
@property
|
|
62
|
+
def allowed_origins(self) -> tuple[str, ...]:
|
|
63
|
+
"""Return the origins besides the gateway's own that may call it, and may write to it.
|
|
64
|
+
|
|
65
|
+
Empty when there is no token. The token is what keeps people out; an origin is not a credential, so letting a
|
|
66
|
+
native client's origin through is only safe while every request still has to show the token.
|
|
67
|
+
"""
|
|
68
|
+
if self.open:
|
|
69
|
+
return ()
|
|
70
|
+
return tuple(dict.fromkeys((*APP_ORIGINS, *self.client_origins)))
|
|
71
|
+
|
|
53
72
|
@property
|
|
54
73
|
def workspace(self) -> Path:
|
|
55
74
|
"""Return the directory the gateway's own session works in: it has nothing to edit, and its name is public."""
|
|
@@ -1376,6 +1376,64 @@ a.member:hover { background: var(--hover);
|
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
1378
|
|
|
1379
|
+
/* src/components/shell/ServerGate.vue */
|
|
1380
|
+
|
|
1381
|
+
.gate { height: 100%; overflow-y: auto; background: var(--surface-2); overscroll-behavior: contain;
|
|
1382
|
+
}
|
|
1383
|
+
.gate-inner {
|
|
1384
|
+
width: 100%; max-width: 34rem; margin: 0 auto; padding: calc(24px + var(--safe-top)) 16px calc(28px + var(--safe-bottom));
|
|
1385
|
+
display: flex; flex-direction: column; gap: 12px;
|
|
1386
|
+
}
|
|
1387
|
+
.gate-head { display: flex; align-items: center; gap: 14px; padding: 8px 4px 4px;
|
|
1388
|
+
}
|
|
1389
|
+
.gate-head img { object-fit: contain; flex: none;
|
|
1390
|
+
}
|
|
1391
|
+
.gate-head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15;
|
|
1392
|
+
}
|
|
1393
|
+
.gate-head p { margin: 4px 0 0; color: var(--muted); font-size: 0.92rem; line-height: 1.35;
|
|
1394
|
+
}
|
|
1395
|
+
.gate-form { margin: 0; padding-bottom: 4px;
|
|
1396
|
+
}
|
|
1397
|
+
.gate-field { display: flex; flex-direction: column; gap: 4px; padding: 9px 16px;
|
|
1398
|
+
}
|
|
1399
|
+
.gate-field + .gate-field { border-top: 1px solid var(--divider);
|
|
1400
|
+
}
|
|
1401
|
+
.gate-field span { color: var(--muted); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
|
|
1402
|
+
}
|
|
1403
|
+
.gate-field input { width: 100%; min-width: 0; border: 0; outline: 0; background: none; color: var(--text); font-size: 1rem; padding: 4px 0;
|
|
1404
|
+
}
|
|
1405
|
+
.gate-server { display: flex; align-items: center; gap: 2px; padding-right: 4px;
|
|
1406
|
+
}
|
|
1407
|
+
.gate-server + .gate-server { border-top: 1px solid var(--divider);
|
|
1408
|
+
}
|
|
1409
|
+
.gate-pick {
|
|
1410
|
+
display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; min-height: 64px; padding: 8px 12px 8px 16px;
|
|
1411
|
+
text-align: left; color: inherit; transition: background 0.12s;
|
|
1412
|
+
}
|
|
1413
|
+
.gate-pick:hover { background: var(--hover);
|
|
1414
|
+
}
|
|
1415
|
+
.gate-plug { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: var(--accent-soft); color: var(--accent); flex: none;
|
|
1416
|
+
}
|
|
1417
|
+
.gate-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
|
|
1418
|
+
}
|
|
1419
|
+
.gate-text b { font-weight: 600; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1420
|
+
}
|
|
1421
|
+
.gate-text small { color: var(--muted); font-family: var(--mono); font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1422
|
+
}
|
|
1423
|
+
.gate-active { color: var(--ok); flex: none;
|
|
1424
|
+
}
|
|
1425
|
+
.gate-hint { margin: 0; padding: 4px 6px; color: var(--muted); font-size: 0.8rem; line-height: 1.4; text-align: center;
|
|
1426
|
+
}
|
|
1427
|
+
.gate .iconbtn.danger { color: var(--bad);
|
|
1428
|
+
}
|
|
1429
|
+
.gate .iconbtn.danger:hover { background: var(--bad-soft);
|
|
1430
|
+
}
|
|
1431
|
+
@media (max-width: 719px) {
|
|
1432
|
+
.gate-inner { padding-left: 10px; padding-right: 10px;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
|
|
1379
1437
|
/* src/components/shell/WelcomeMain.vue */
|
|
1380
1438
|
|
|
1381
1439
|
.welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px; text-align: center; color: var(--muted); background: var(--surface-2);
|