gramlot-py-server 0.2.1__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.
- gramlot_py_server/__init__.py +2 -0
- gramlot_py_server/django.py +156 -0
- gramlot_py_server/fastapi.py +191 -0
- gramlot_py_server/flask.py +159 -0
- gramlot_py_server/kajenn.py +177 -0
- gramlot_py_server/py.typed +0 -0
- gramlot_py_server/uvicorn.py +239 -0
- gramlot_py_server-0.2.1.dist-info/METADATA +266 -0
- gramlot_py_server-0.2.1.dist-info/RECORD +12 -0
- gramlot_py_server-0.2.1.dist-info/WHEEL +4 -0
- gramlot_py_server-0.2.1.dist-info/licenses/LICENSE +176 -0
- gramlot_py_server-0.2.1.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Translate the Gramlot Host protocol into Django views and URLs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from secrets import token_urlsafe
|
|
9
|
+
|
|
10
|
+
from asgiref.sync import async_to_sync
|
|
11
|
+
from django.http import FileResponse, HttpResponse
|
|
12
|
+
from django.urls import path
|
|
13
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
14
|
+
from gramlot.server import (
|
|
15
|
+
FileHost,
|
|
16
|
+
HostCapacity,
|
|
17
|
+
PageExpired,
|
|
18
|
+
PageNotFound,
|
|
19
|
+
SourceNotFound,
|
|
20
|
+
runtime_asset,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
MAX_REQUEST_BYTES = 4096
|
|
24
|
+
OWNER_COOKIE = "gramlot_owner"
|
|
25
|
+
COMPANION_MEDIA_TYPES = {
|
|
26
|
+
".css": "text/css; charset=utf-8",
|
|
27
|
+
"_aux.js": "text/javascript; charset=utf-8",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Pages:
|
|
32
|
+
"""Own a Gramlot ``FileHost`` and expose it at one Django URLconf mount point.
|
|
33
|
+
|
|
34
|
+
Include ``urls`` at the same ``mount_path`` supplied here: ``mount_path`` is
|
|
35
|
+
passed to ``open_page`` as the mount prefix of browser URLs. The cookie
|
|
36
|
+
associates browser requests with in-process page records; it is not
|
|
37
|
+
authentication.
|
|
38
|
+
|
|
39
|
+
GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
|
|
40
|
+
pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
|
|
41
|
+
there. Every other file of the folder is not served.
|
|
42
|
+
|
|
43
|
+
``content_security_policy`` is the application's policy, sent as the
|
|
44
|
+
``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
|
|
45
|
+
replaced by the nonce that ``open_page`` puts on the bootstrap script.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
|
|
49
|
+
max_pages: int = 1000, content_security_policy: str | None = None) -> None:
|
|
50
|
+
self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
|
|
51
|
+
self.content_security_policy = content_security_policy
|
|
52
|
+
self.host = FileHost(
|
|
53
|
+
pages,
|
|
54
|
+
runtime_url="/assets/gramlot.js",
|
|
55
|
+
main_url="/gramlot/main",
|
|
56
|
+
source_url="/gramlot/source",
|
|
57
|
+
close_url="/gramlot/close",
|
|
58
|
+
page_ttl=page_ttl,
|
|
59
|
+
max_pages=max_pages,
|
|
60
|
+
)
|
|
61
|
+
self.urls = [
|
|
62
|
+
path("assets/gramlot.js", self.asset),
|
|
63
|
+
path("gramlot/main", csrf_exempt(self.main)),
|
|
64
|
+
path("gramlot/source", csrf_exempt(self.source)),
|
|
65
|
+
path("gramlot/close", csrf_exempt(self.close)),
|
|
66
|
+
path("", self.page),
|
|
67
|
+
path("<path:page_path>", self.page),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def asset(self, request):
|
|
71
|
+
if request.method not in ("GET", "HEAD"):
|
|
72
|
+
return HttpResponse(status=405)
|
|
73
|
+
response = FileResponse(runtime_asset().open("rb"), content_type="text/javascript")
|
|
74
|
+
response["Cache-Control"] = "no-cache"
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def page(self, request, page_path=""):
|
|
78
|
+
suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
|
|
79
|
+
if suffix is not None:
|
|
80
|
+
return self.companion(request, page_path, suffix)
|
|
81
|
+
if request.method not in ("GET", "HEAD"):
|
|
82
|
+
return HttpResponse(status=405)
|
|
83
|
+
owner = request.COOKIES.get(OWNER_COOKIE) or token_urlsafe(24)
|
|
84
|
+
try:
|
|
85
|
+
opened = async_to_sync(self.host.open_page)(page_path, owner=owner, prefix=self.mount_path)
|
|
86
|
+
except PageNotFound:
|
|
87
|
+
return HttpResponse("Page not found", status=404)
|
|
88
|
+
except HostCapacity:
|
|
89
|
+
return HttpResponse("Page capacity reached", status=503)
|
|
90
|
+
response = HttpResponse(opened.html, content_type="text/html; charset=utf-8")
|
|
91
|
+
response["Cache-Control"] = "no-store"
|
|
92
|
+
if self.content_security_policy is not None:
|
|
93
|
+
policy = self.content_security_policy.replace("{nonce}", opened.nonce)
|
|
94
|
+
response["Content-Security-Policy"] = policy
|
|
95
|
+
response.set_cookie(OWNER_COOKIE, owner, path=self.mount_path or "/", httponly=True, samesite="Lax")
|
|
96
|
+
return response
|
|
97
|
+
|
|
98
|
+
def companion(self, request, page_path, suffix):
|
|
99
|
+
"""Serve the file of ``page_path`` when its real path is below the pages folder."""
|
|
100
|
+
if request.method not in ("GET", "HEAD"):
|
|
101
|
+
return HttpResponse(status=405)
|
|
102
|
+
root = self.host.pages_dir.resolve()
|
|
103
|
+
real = root.joinpath(*page_path.strip("/").split("/")).resolve()
|
|
104
|
+
if not (real.is_relative_to(root) and real.is_file()):
|
|
105
|
+
return HttpResponse("Not found", status=404)
|
|
106
|
+
body = b"" if request.method == "HEAD" else real.read_bytes()
|
|
107
|
+
response = HttpResponse(body, content_type=COMPANION_MEDIA_TYPES[suffix])
|
|
108
|
+
response["Cache-Control"] = "no-store"
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
def main(self, request):
|
|
112
|
+
return self._operation(request, "main")
|
|
113
|
+
|
|
114
|
+
def source(self, request):
|
|
115
|
+
return self._operation(request, "source")
|
|
116
|
+
|
|
117
|
+
def close(self, request):
|
|
118
|
+
return self._operation(request, "close")
|
|
119
|
+
|
|
120
|
+
def _operation(self, request, operation):
|
|
121
|
+
if request.method != "POST":
|
|
122
|
+
return HttpResponse(status=405)
|
|
123
|
+
if request.content_type != "application/json":
|
|
124
|
+
return HttpResponse("Expected application/json", status=415)
|
|
125
|
+
if len(request.body) > MAX_REQUEST_BYTES:
|
|
126
|
+
return HttpResponse("Request too large", status=413)
|
|
127
|
+
try:
|
|
128
|
+
payload = json.loads(request.body)
|
|
129
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
130
|
+
return HttpResponse("Invalid JSON request", status=400)
|
|
131
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
|
|
132
|
+
return HttpResponse("Invalid JSON request", status=400)
|
|
133
|
+
if operation == "source" and not isinstance(payload.get("params", {}), dict):
|
|
134
|
+
return HttpResponse("Source params must be a dictionary", status=400)
|
|
135
|
+
owner = request.COOKIES.get(OWNER_COOKIE)
|
|
136
|
+
try:
|
|
137
|
+
if operation == "main":
|
|
138
|
+
result = async_to_sync(self.host.main)(payload["pageId"], owner=owner)
|
|
139
|
+
elif operation == "source":
|
|
140
|
+
result = async_to_sync(self.host.source)(
|
|
141
|
+
payload["pageId"], payload.get("method"), payload.get("params", {}),
|
|
142
|
+
owner=owner,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
self.host.close_page(payload["pageId"], owner=owner)
|
|
146
|
+
result = json.dumps({"ok": True})
|
|
147
|
+
except PageExpired:
|
|
148
|
+
return HttpResponse("Unknown page", status=404)
|
|
149
|
+
except SourceNotFound:
|
|
150
|
+
return HttpResponse("Unknown Source method", status=404)
|
|
151
|
+
response = HttpResponse(result, content_type="application/json")
|
|
152
|
+
response["Cache-Control"] = "no-store"
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["Pages"]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""FastAPI integration for the Gramlot ``Host`` protocol."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from secrets import token_urlsafe
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
12
|
+
from fastapi.responses import HTMLResponse, Response
|
|
13
|
+
from gramlot.server import (
|
|
14
|
+
FileHost,
|
|
15
|
+
HostCapacity,
|
|
16
|
+
PageExpired,
|
|
17
|
+
PageNotFound,
|
|
18
|
+
SourceNotFound,
|
|
19
|
+
runtime_asset,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
MAX_REQUEST_BYTES = 4096
|
|
23
|
+
OWNER_COOKIE = "gramlot_owner"
|
|
24
|
+
COMPANION_MEDIA_TYPES = {
|
|
25
|
+
".css": "text/css; charset=utf-8",
|
|
26
|
+
"_aux.js": "text/javascript; charset=utf-8",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Pages:
|
|
31
|
+
"""Own a Gramlot ``FileHost`` and its FastAPI route translations.
|
|
32
|
+
|
|
33
|
+
The routes are included at ``mount_path``, which is also passed to
|
|
34
|
+
``open_page`` as the mount prefix of browser URLs.
|
|
35
|
+
|
|
36
|
+
GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
|
|
37
|
+
pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
|
|
38
|
+
there. Every other file of the folder is not served.
|
|
39
|
+
|
|
40
|
+
``content_security_policy`` is the application's policy, sent as the
|
|
41
|
+
``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
|
|
42
|
+
replaced by the nonce that ``open_page`` puts on the bootstrap script.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
|
|
46
|
+
max_pages: int = 1000, content_security_policy: str | None = None) -> None:
|
|
47
|
+
self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
|
|
48
|
+
self.content_security_policy = content_security_policy
|
|
49
|
+
self.host = FileHost(
|
|
50
|
+
pages,
|
|
51
|
+
runtime_url="/assets/gramlot.js",
|
|
52
|
+
main_url="/gramlot/main",
|
|
53
|
+
source_url="/gramlot/source",
|
|
54
|
+
close_url="/gramlot/close",
|
|
55
|
+
page_ttl=page_ttl,
|
|
56
|
+
max_pages=max_pages,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def mount(self, app: FastAPI) -> None:
|
|
60
|
+
router = APIRouter(prefix=self.mount_path)
|
|
61
|
+
router.add_api_route("/assets/gramlot.js", self.asset, methods=["GET", "HEAD"])
|
|
62
|
+
router.add_api_route("/gramlot/main", self.main, methods=["POST"])
|
|
63
|
+
router.add_api_route("/gramlot/source", self.source, methods=["POST"])
|
|
64
|
+
router.add_api_route("/gramlot/close", self.close, methods=["POST"])
|
|
65
|
+
router.add_api_route("/", self.page, methods=["GET", "HEAD"])
|
|
66
|
+
router.add_api_route("/{page_path:path}", self.page, methods=["GET", "HEAD"])
|
|
67
|
+
app.include_router(router)
|
|
68
|
+
app.router.add_event_handler("shutdown", self.shutdown)
|
|
69
|
+
|
|
70
|
+
async def shutdown(self) -> None:
|
|
71
|
+
self.host._pages.clear()
|
|
72
|
+
|
|
73
|
+
async def asset(self, request: Request) -> Response:
|
|
74
|
+
body = b"" if request.method == "HEAD" else runtime_asset().read_bytes()
|
|
75
|
+
return Response(body, media_type="text/javascript", headers={"Cache-Control": "no-cache"})
|
|
76
|
+
|
|
77
|
+
async def page(self, request: Request, page_path: str = "") -> Response:
|
|
78
|
+
suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
|
|
79
|
+
if suffix is not None:
|
|
80
|
+
return await self.companion(request, page_path, suffix)
|
|
81
|
+
if request.method != "GET":
|
|
82
|
+
return Response(status_code=405, headers={"Allow": "GET"})
|
|
83
|
+
owner = request.cookies.get(OWNER_COOKIE) or token_urlsafe(24)
|
|
84
|
+
try:
|
|
85
|
+
opened = await self.host.open_page(page_path, owner=owner, prefix=self.mount_path)
|
|
86
|
+
except PageNotFound:
|
|
87
|
+
return Response("Page not found", status_code=404)
|
|
88
|
+
except HostCapacity:
|
|
89
|
+
return Response("Page capacity reached", status_code=503)
|
|
90
|
+
headers = {"Cache-Control": "no-store"}
|
|
91
|
+
if self.content_security_policy is not None:
|
|
92
|
+
headers["Content-Security-Policy"] = self.content_security_policy.replace("{nonce}", opened.nonce)
|
|
93
|
+
response = HTMLResponse(opened.html, headers=headers)
|
|
94
|
+
response.set_cookie(
|
|
95
|
+
OWNER_COOKIE,
|
|
96
|
+
owner,
|
|
97
|
+
path=self.mount_path or "/",
|
|
98
|
+
httponly=True,
|
|
99
|
+
samesite="lax",
|
|
100
|
+
)
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
async def companion(self, request: Request, page_path: str, suffix: str) -> Response:
|
|
104
|
+
"""Serve the file of ``page_path`` when its real path is below the pages folder."""
|
|
105
|
+
root = self.host.pages_dir.resolve()
|
|
106
|
+
real = root.joinpath(*page_path.strip("/").split("/")).resolve()
|
|
107
|
+
if not (real.is_relative_to(root) and real.is_file()):
|
|
108
|
+
return Response("Not found", status_code=404)
|
|
109
|
+
body = b"" if request.method == "HEAD" else await asyncio.to_thread(real.read_bytes)
|
|
110
|
+
return Response(body, headers={"Content-Type": COMPANION_MEDIA_TYPES[suffix],
|
|
111
|
+
"Cache-Control": "no-store"})
|
|
112
|
+
|
|
113
|
+
async def main(self, request: Request) -> Response:
|
|
114
|
+
return await self._operation(request, "main")
|
|
115
|
+
|
|
116
|
+
async def source(self, request: Request) -> Response:
|
|
117
|
+
return await self._operation(request, "source")
|
|
118
|
+
|
|
119
|
+
async def close(self, request: Request) -> Response:
|
|
120
|
+
return await self._operation(request, "close")
|
|
121
|
+
|
|
122
|
+
async def _operation(self, request: Request, operation: str) -> Response:
|
|
123
|
+
if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json":
|
|
124
|
+
return Response("Expected application/json", status_code=415)
|
|
125
|
+
try:
|
|
126
|
+
raw = await self._body(request)
|
|
127
|
+
payload = json.loads(raw)
|
|
128
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
|
|
129
|
+
raise ValueError
|
|
130
|
+
except RequestTooLarge:
|
|
131
|
+
return Response("Request too large", status_code=413)
|
|
132
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
133
|
+
return Response("Invalid JSON request", status_code=400)
|
|
134
|
+
owner = request.cookies.get(OWNER_COOKIE)
|
|
135
|
+
if operation == "source" and not isinstance(payload.get("params", {}), dict):
|
|
136
|
+
return Response("Source params must be a dictionary", status_code=400)
|
|
137
|
+
try:
|
|
138
|
+
if operation == "main":
|
|
139
|
+
result = await self.host.main(payload["pageId"], owner=owner)
|
|
140
|
+
elif operation == "source":
|
|
141
|
+
result = await self.host.source(
|
|
142
|
+
payload["pageId"], payload.get("method"), payload.get("params", {}), owner=owner
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
self.host.close_page(payload["pageId"], owner=owner)
|
|
146
|
+
result = json.dumps({"ok": True})
|
|
147
|
+
except PageExpired:
|
|
148
|
+
return Response("Unknown page", status_code=404)
|
|
149
|
+
except SourceNotFound:
|
|
150
|
+
return Response("Unknown Source method", status_code=404)
|
|
151
|
+
return Response(result, media_type="application/json", headers={"Cache-Control": "no-store"})
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
async def _body(request: Request) -> str:
|
|
155
|
+
length = request.headers.get("content-length")
|
|
156
|
+
if length and length.isdigit() and int(length) > MAX_REQUEST_BYTES:
|
|
157
|
+
raise RequestTooLarge
|
|
158
|
+
body = bytearray()
|
|
159
|
+
async for chunk in request.stream():
|
|
160
|
+
body.extend(chunk)
|
|
161
|
+
if len(body) > MAX_REQUEST_BYTES:
|
|
162
|
+
raise RequestTooLarge
|
|
163
|
+
return body.decode()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class RequestTooLarge(ValueError):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Application(FastAPI):
|
|
171
|
+
"""Ready-made FastAPI application serving Gramlot pages."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
|
|
174
|
+
max_pages: int = 1000, content_security_policy: str | None = None,
|
|
175
|
+
**fastapi_options) -> None:
|
|
176
|
+
super().__init__(**fastapi_options)
|
|
177
|
+
self.gramlot_pages = mount_pages(
|
|
178
|
+
self, pages, mount_path=mount_path, page_ttl=page_ttl, max_pages=max_pages,
|
|
179
|
+
content_security_policy=content_security_policy,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def mount_pages(app: FastAPI, pages: str | Path, **options) -> Pages:
|
|
184
|
+
"""Mount the bounded Gramlot page protocol on an existing FastAPI app."""
|
|
185
|
+
|
|
186
|
+
integration = Pages(pages, **options)
|
|
187
|
+
integration.mount(app)
|
|
188
|
+
return integration
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = ["Application", "Pages", "mount_pages"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Flask integration for the Gramlot ``Host`` protocol."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from secrets import token_urlsafe
|
|
10
|
+
|
|
11
|
+
from flask import Blueprint, Response, request
|
|
12
|
+
from gramlot.server import (
|
|
13
|
+
FileHost,
|
|
14
|
+
HostCapacity,
|
|
15
|
+
PageExpired,
|
|
16
|
+
PageNotFound,
|
|
17
|
+
SourceNotFound,
|
|
18
|
+
runtime_asset,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MAX_REQUEST_BYTES = 4096
|
|
22
|
+
OWNER_COOKIE = "gramlot_owner"
|
|
23
|
+
COMPANION_MEDIA_TYPES = {
|
|
24
|
+
".css": "text/css; charset=utf-8",
|
|
25
|
+
"_aux.js": "text/javascript; charset=utf-8",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Pages:
|
|
30
|
+
"""Own a Gramlot ``FileHost`` and its WSGI route translations.
|
|
31
|
+
|
|
32
|
+
The blueprint is registered at ``mount_path``, which is also passed to
|
|
33
|
+
``open_page`` as the mount prefix of browser URLs.
|
|
34
|
+
|
|
35
|
+
GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
|
|
36
|
+
pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
|
|
37
|
+
there. Every other file of the folder is not served.
|
|
38
|
+
|
|
39
|
+
``content_security_policy`` is the application's policy, sent as the
|
|
40
|
+
``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
|
|
41
|
+
replaced by the nonce that ``open_page`` puts on the bootstrap script.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
|
|
45
|
+
max_pages: int = 1000, content_security_policy: str | None = None) -> None:
|
|
46
|
+
self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
|
|
47
|
+
self.content_security_policy = content_security_policy
|
|
48
|
+
self.host = FileHost(
|
|
49
|
+
pages,
|
|
50
|
+
runtime_url="/assets/gramlot.js",
|
|
51
|
+
main_url="/gramlot/main",
|
|
52
|
+
source_url="/gramlot/source",
|
|
53
|
+
close_url="/gramlot/close",
|
|
54
|
+
page_ttl=page_ttl,
|
|
55
|
+
max_pages=max_pages,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def blueprint(self) -> Blueprint:
|
|
59
|
+
name = "gramlot_pages_" + (self.mount_path.strip("/") or "root").replace("/", "_")
|
|
60
|
+
blueprint = Blueprint(name, __name__, url_prefix=self.mount_path or None)
|
|
61
|
+
blueprint.add_url_rule("/assets/gramlot.js", "asset", self.asset, methods=["GET", "HEAD"])
|
|
62
|
+
blueprint.add_url_rule("/gramlot/main", "main", self.main, methods=["POST"])
|
|
63
|
+
blueprint.add_url_rule("/gramlot/source", "source", self.source, methods=["POST"])
|
|
64
|
+
blueprint.add_url_rule("/gramlot/close", "close", self.close, methods=["POST"])
|
|
65
|
+
blueprint.add_url_rule("/", "index", self.page, defaults={"page_path": ""})
|
|
66
|
+
blueprint.add_url_rule("/<path:page_path>", "page", self.page)
|
|
67
|
+
return blueprint
|
|
68
|
+
|
|
69
|
+
def asset(self) -> Response:
|
|
70
|
+
body = b"" if request.method == "HEAD" else runtime_asset().read_bytes()
|
|
71
|
+
return Response(body, mimetype="text/javascript", headers={"Cache-Control": "no-cache"})
|
|
72
|
+
|
|
73
|
+
def page(self, page_path="") -> Response:
|
|
74
|
+
suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
|
|
75
|
+
if suffix is not None:
|
|
76
|
+
return self.companion(page_path, suffix)
|
|
77
|
+
owner = request.cookies.get(OWNER_COOKIE) or token_urlsafe(24)
|
|
78
|
+
try:
|
|
79
|
+
opened = asyncio.run(self.host.open_page(page_path, owner=owner, prefix=self.mount_path))
|
|
80
|
+
except PageNotFound:
|
|
81
|
+
return Response("Page not found", status=404)
|
|
82
|
+
except HostCapacity:
|
|
83
|
+
return Response("Page capacity reached", status=503)
|
|
84
|
+
headers = {"Cache-Control": "no-store"}
|
|
85
|
+
if self.content_security_policy is not None:
|
|
86
|
+
headers["Content-Security-Policy"] = self.content_security_policy.replace("{nonce}", opened.nonce)
|
|
87
|
+
response = Response(opened.html, mimetype="text/html", headers=headers)
|
|
88
|
+
response.set_cookie(
|
|
89
|
+
OWNER_COOKIE,
|
|
90
|
+
owner,
|
|
91
|
+
path=self.mount_path or "/",
|
|
92
|
+
httponly=True,
|
|
93
|
+
samesite="Lax",
|
|
94
|
+
)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
def companion(self, page_path: str, suffix: str) -> Response:
|
|
98
|
+
"""Serve the file of ``page_path`` when its real path is below the pages folder."""
|
|
99
|
+
root = self.host.pages_dir.resolve()
|
|
100
|
+
real = root.joinpath(*page_path.strip("/").split("/")).resolve()
|
|
101
|
+
if not (real.is_relative_to(root) and real.is_file()):
|
|
102
|
+
return Response("Not found", status=404)
|
|
103
|
+
body = b"" if request.method == "HEAD" else real.read_bytes()
|
|
104
|
+
return Response(body, content_type=COMPANION_MEDIA_TYPES[suffix],
|
|
105
|
+
headers={"Cache-Control": "no-store"})
|
|
106
|
+
|
|
107
|
+
def main(self) -> Response:
|
|
108
|
+
return self._operation("main")
|
|
109
|
+
|
|
110
|
+
def source(self) -> Response:
|
|
111
|
+
return self._operation("source")
|
|
112
|
+
|
|
113
|
+
def close(self) -> Response:
|
|
114
|
+
return self._operation("close")
|
|
115
|
+
|
|
116
|
+
def _operation(self, operation: str) -> Response:
|
|
117
|
+
if request.mimetype != "application/json":
|
|
118
|
+
return Response("Expected application/json", status=415)
|
|
119
|
+
if request.content_length is not None and request.content_length > MAX_REQUEST_BYTES:
|
|
120
|
+
return Response("Request too large", status=413)
|
|
121
|
+
raw = request.get_data(cache=False)
|
|
122
|
+
if len(raw) > MAX_REQUEST_BYTES:
|
|
123
|
+
return Response("Request too large", status=413)
|
|
124
|
+
try:
|
|
125
|
+
payload = json.loads(raw)
|
|
126
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
|
|
127
|
+
raise ValueError
|
|
128
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
129
|
+
return Response("Invalid JSON request", status=400)
|
|
130
|
+
owner = request.cookies.get(OWNER_COOKIE)
|
|
131
|
+
if operation == "source" and not isinstance(payload.get("params", {}), dict):
|
|
132
|
+
return Response("Source params must be a dictionary", status=400)
|
|
133
|
+
try:
|
|
134
|
+
if operation == "main":
|
|
135
|
+
result = asyncio.run(self.host.main(payload["pageId"], owner=owner))
|
|
136
|
+
elif operation == "source":
|
|
137
|
+
result = asyncio.run(self.host.source(
|
|
138
|
+
payload["pageId"], payload.get("method"), payload.get("params", {}), owner=owner
|
|
139
|
+
))
|
|
140
|
+
else:
|
|
141
|
+
self.host.close_page(payload["pageId"], owner=owner)
|
|
142
|
+
result = json.dumps({"ok": True})
|
|
143
|
+
except PageExpired:
|
|
144
|
+
return Response("Unknown page", status=404)
|
|
145
|
+
except SourceNotFound:
|
|
146
|
+
return Response("Unknown Source method", status=404)
|
|
147
|
+
return Response(result, mimetype="application/json", headers={"Cache-Control": "no-store"})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def mount_pages(app, pages: str | Path, **options) -> Pages:
|
|
151
|
+
"""Mount the bounded Gramlot page protocol on an existing Flask app."""
|
|
152
|
+
|
|
153
|
+
integration = Pages(pages, **options)
|
|
154
|
+
app.register_blueprint(integration.blueprint())
|
|
155
|
+
app.extensions.setdefault("gramlot_pages", {})[integration.mount_path] = integration
|
|
156
|
+
return integration
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["Pages", "mount_pages"]
|