toolframe-web 0.1.0__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.
- toolframe_web/__init__.py +5 -0
- toolframe_web/asset_delivery.py +51 -0
- toolframe_web/assets.py +205 -0
- toolframe_web/authoring.py +29 -0
- toolframe_web/cli.py +29 -0
- toolframe_web/plugin.py +508 -0
- toolframe_web/policy.py +31 -0
- toolframe_web/prepare.py +447 -0
- toolframe_web/render.py +79 -0
- toolframe_web/request_body.py +95 -0
- toolframe_web/templates/notes/.gitignore +7 -0
- toolframe_web/templates/notes/LICENSE +21 -0
- toolframe_web/templates/notes/README.md +63 -0
- toolframe_web/templates/notes/pyproject.toml +25 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/__init__.py +1 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/contracts.py +50 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/plugin.py +76 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/service.py +81 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/__init__.py +0 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/app/__init__.py +16 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/app/index.js +3 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/app/styles/input.css +1 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/__init__.py +0 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/__init__.py +6 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/api/__init__.py +17 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/index.js +1 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/__init__.py +26 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/count.js +6 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/__init__.py +0 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/index.js +47 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/routes.py +94 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/index.html +23 -0
- toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/notes.html +29 -0
- toolframe_web/templates/notes/tests/test_contracts.py +30 -0
- toolframe_web/templates/notes/web-assets.toml +54 -0
- toolframe_web-0.1.0.dist-info/METADATA +83 -0
- toolframe_web-0.1.0.dist-info/RECORD +43 -0
- toolframe_web-0.1.0.dist-info/WHEEL +4 -0
- toolframe_web-0.1.0.dist-info/entry_points.txt +5 -0
- toolframe_web-0.1.0.dist-info/extra_metadata/toolframe-authoring.json +29 -0
- toolframe_web-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolframe_web-0.1.0.dist-info/licenses/NOTICE +7 -0
- toolframe_web-0.1.0.dist-info/licenses/src/toolframe_web/templates/notes/LICENSE +21 -0
toolframe_web/plugin.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""HTTP adapter for prepared SDK Web applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import inspect
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import MappingProxyType, SimpleNamespace
|
|
14
|
+
from urllib.parse import parse_qs
|
|
15
|
+
|
|
16
|
+
import anyio
|
|
17
|
+
from jinja2 import DictLoader, Environment, PackageLoader, StrictUndefined
|
|
18
|
+
from starlette.applications import Starlette
|
|
19
|
+
from starlette.exceptions import HTTPException
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import JSONResponse, Response
|
|
22
|
+
from starlette.routing import Route
|
|
23
|
+
from toolframe_sdk import (
|
|
24
|
+
AuthorizationSpec,
|
|
25
|
+
Plugin,
|
|
26
|
+
PluginDefinition,
|
|
27
|
+
PluginError,
|
|
28
|
+
WebApp,
|
|
29
|
+
WebContext,
|
|
30
|
+
WebRequest,
|
|
31
|
+
WebResponse,
|
|
32
|
+
load_object,
|
|
33
|
+
)
|
|
34
|
+
from toolframe_sdk.http import HttpServer
|
|
35
|
+
from toolframe_sdk.web import WebAppCatalog
|
|
36
|
+
|
|
37
|
+
from .asset_delivery import deliver_asset
|
|
38
|
+
from .assets import AssetSet
|
|
39
|
+
from .policy import application_policy
|
|
40
|
+
from .request_body import FORM_TYPES, RequestBody
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Application:
|
|
45
|
+
resource: object
|
|
46
|
+
app: WebApp
|
|
47
|
+
assets: AssetSet
|
|
48
|
+
catalog: WebAppCatalog
|
|
49
|
+
templates: Environment
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ERROR_STATUS = {
|
|
53
|
+
"authentication_required": 401,
|
|
54
|
+
"session_invalid": 401,
|
|
55
|
+
"forbidden": 403,
|
|
56
|
+
"csrf_failed": 403,
|
|
57
|
+
"web_asset_missing": 404,
|
|
58
|
+
"web_revision_stale": 409,
|
|
59
|
+
"stale_catalog": 409,
|
|
60
|
+
"web_context_closed": 409,
|
|
61
|
+
"invalid_request": 422,
|
|
62
|
+
"request_too_large": 413,
|
|
63
|
+
"rate_limited": 429,
|
|
64
|
+
"presentation_unavailable": 503,
|
|
65
|
+
"auth_unavailable": 503,
|
|
66
|
+
"auth_transition": 503,
|
|
67
|
+
"plugin_reloading": 503,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WebProvider(Plugin):
|
|
72
|
+
def __init__(self, context):
|
|
73
|
+
super().__init__(context)
|
|
74
|
+
self.server = None
|
|
75
|
+
self.snapshot = (None, {})
|
|
76
|
+
self.staged = {}
|
|
77
|
+
self.limiter = None
|
|
78
|
+
self.handlers = set()
|
|
79
|
+
self.closing = False
|
|
80
|
+
self.cache = Path(context.state_directory) if context.state_directory else None
|
|
81
|
+
|
|
82
|
+
async def activate(self):
|
|
83
|
+
self.limiter = anyio.CapacityLimiter(8)
|
|
84
|
+
self.server = HttpServer(
|
|
85
|
+
Starlette(
|
|
86
|
+
routes=[
|
|
87
|
+
Route(
|
|
88
|
+
"/{path:path}",
|
|
89
|
+
self.handle,
|
|
90
|
+
methods=[
|
|
91
|
+
"GET",
|
|
92
|
+
"HEAD",
|
|
93
|
+
"POST",
|
|
94
|
+
"PUT",
|
|
95
|
+
"PATCH",
|
|
96
|
+
"DELETE",
|
|
97
|
+
"OPTIONS",
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
await self.server.start()
|
|
104
|
+
|
|
105
|
+
async def health(self):
|
|
106
|
+
return {
|
|
107
|
+
"runtime": "ready",
|
|
108
|
+
"business": "not_provided",
|
|
109
|
+
"http_url": self.server.url if self.server else None,
|
|
110
|
+
"capabilities": ["web-app-v1", "gateway-endpoints-v1"],
|
|
111
|
+
"epoch": self.snapshot[0],
|
|
112
|
+
"apps": sorted(self.snapshot[1]),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async def dispose(self):
|
|
116
|
+
self.closing = True
|
|
117
|
+
if self.server:
|
|
118
|
+
await self.server.close()
|
|
119
|
+
await super().dispose()
|
|
120
|
+
|
|
121
|
+
async def prepare(self, resources, epoch):
|
|
122
|
+
apps = {}
|
|
123
|
+
total = 0
|
|
124
|
+
for resource in resources:
|
|
125
|
+
if resource.resource.kind != "web.app" or resource.key in apps:
|
|
126
|
+
raise PluginError(
|
|
127
|
+
"unsupported_resource",
|
|
128
|
+
"Web provider requires distinct web.app resources",
|
|
129
|
+
)
|
|
130
|
+
data = resource.resource.data
|
|
131
|
+
value = load_object(data["entrypoint"])
|
|
132
|
+
app = value if isinstance(value, WebApp) else value()
|
|
133
|
+
if inspect.isawaitable(app):
|
|
134
|
+
app = await app
|
|
135
|
+
if not isinstance(app, WebApp):
|
|
136
|
+
raise PluginError(
|
|
137
|
+
"web_contract_unsupported", "Web entrypoint must return SDK WebApp"
|
|
138
|
+
)
|
|
139
|
+
assets = AssetSet.load(data["package"], data["assets"])
|
|
140
|
+
if app.spa_fallback and not any(
|
|
141
|
+
item.path == app.spa_fallback and item.media_type == "text/html"
|
|
142
|
+
for item in assets.manifest.files
|
|
143
|
+
):
|
|
144
|
+
raise PluginError(
|
|
145
|
+
"web_contract_unsupported",
|
|
146
|
+
"SPA fallback must name a declared HTML asset",
|
|
147
|
+
)
|
|
148
|
+
total += sum(item.size for item in assets.manifest.files)
|
|
149
|
+
if total > 128 * 1024 * 1024:
|
|
150
|
+
raise PluginError(
|
|
151
|
+
"web_assets_too_large", "Web cohort assets exceed 128 MiB"
|
|
152
|
+
)
|
|
153
|
+
catalog = WebAppCatalog(
|
|
154
|
+
routes=tuple(route.spec() for route in app.routes),
|
|
155
|
+
asset_digest=assets.manifest.digest,
|
|
156
|
+
asset_paths=tuple(item.path for item in assets.manifest.files),
|
|
157
|
+
asset_visibility=assets.manifest.document.visibility,
|
|
158
|
+
csp=dict(app.csp),
|
|
159
|
+
spa_fallback=app.spa_fallback,
|
|
160
|
+
)
|
|
161
|
+
template_root = importlib.resources.files(data["package"]).joinpath(
|
|
162
|
+
app.templates
|
|
163
|
+
)
|
|
164
|
+
templates = Environment(
|
|
165
|
+
loader=PackageLoader(data["package"], app.templates)
|
|
166
|
+
if template_root.is_dir()
|
|
167
|
+
else DictLoader({}),
|
|
168
|
+
autoescape=True,
|
|
169
|
+
undefined=StrictUndefined,
|
|
170
|
+
auto_reload=False,
|
|
171
|
+
enable_async=False,
|
|
172
|
+
)
|
|
173
|
+
# Compile templates before publication, so syntax errors preserve the old app.
|
|
174
|
+
for name in templates.list_templates():
|
|
175
|
+
templates.get_template(name)
|
|
176
|
+
apps[resource.key] = Application(resource, app, assets, catalog, templates)
|
|
177
|
+
self.staged[epoch] = apps
|
|
178
|
+
return {
|
|
179
|
+
"epoch": epoch,
|
|
180
|
+
"web_apps": {
|
|
181
|
+
key: app.catalog.model_dump(mode="json") for key, app in apps.items()
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async def commit(self, epoch):
|
|
186
|
+
if epoch not in self.staged:
|
|
187
|
+
raise PluginError("unknown_epoch", "No prepared Web cohort")
|
|
188
|
+
apps = self.staged.pop(epoch)
|
|
189
|
+
if self.cache:
|
|
190
|
+
for app in apps.values():
|
|
191
|
+
await asyncio.to_thread(app.assets.persist, self.cache)
|
|
192
|
+
# Keep the selected and immediately preceding set for each application.
|
|
193
|
+
import json
|
|
194
|
+
|
|
195
|
+
self.cache.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
index = self.cache / "selected.json"
|
|
197
|
+
prior = json.loads(index.read_text()) if index.exists() else {}
|
|
198
|
+
old = prior.get("current", {})
|
|
199
|
+
selected = {key: app.catalog.asset_digest for key, app in apps.items()}
|
|
200
|
+
previous = {
|
|
201
|
+
key: value
|
|
202
|
+
for key, value in prior.get("previous", {}).items()
|
|
203
|
+
if key in selected and value["expires"] > time.time()
|
|
204
|
+
}
|
|
205
|
+
for key, checksum in old.items():
|
|
206
|
+
if key in selected and selected[key] != checksum:
|
|
207
|
+
previous[key] = {"digest": checksum, "expires": time.time() + 600}
|
|
208
|
+
keep = set(selected.values()) | {
|
|
209
|
+
value["digest"] for value in previous.values()
|
|
210
|
+
}
|
|
211
|
+
for item in self.cache.iterdir():
|
|
212
|
+
if item.is_dir() and len(item.name) == 64 and item.name not in keep:
|
|
213
|
+
await asyncio.to_thread(shutil.rmtree, item)
|
|
214
|
+
temporary = index.with_suffix(".tmp")
|
|
215
|
+
temporary.write_text(
|
|
216
|
+
json.dumps({"current": selected, "previous": previous})
|
|
217
|
+
)
|
|
218
|
+
temporary.replace(index)
|
|
219
|
+
self.snapshot = (epoch, apps)
|
|
220
|
+
return {"epoch": epoch}
|
|
221
|
+
|
|
222
|
+
async def abort(self, epoch):
|
|
223
|
+
self.staged.pop(epoch, None)
|
|
224
|
+
return {"epoch": epoch}
|
|
225
|
+
|
|
226
|
+
async def run_sync(self, handler, request, context):
|
|
227
|
+
# A timed-out thread keeps its slot until it actually exits. New requests
|
|
228
|
+
# cannot build an unbounded queue behind abandoned synchronous handlers.
|
|
229
|
+
token = object()
|
|
230
|
+
try:
|
|
231
|
+
self.limiter.acquire_on_behalf_of_nowait(token)
|
|
232
|
+
except anyio.WouldBlock:
|
|
233
|
+
raise PluginError(
|
|
234
|
+
"rate_limited", "Synchronous Web handlers are busy"
|
|
235
|
+
) from None
|
|
236
|
+
|
|
237
|
+
async def execute():
|
|
238
|
+
try:
|
|
239
|
+
return await anyio.to_thread.run_sync(handler, request, context)
|
|
240
|
+
finally:
|
|
241
|
+
self.limiter.release_on_behalf_of(token)
|
|
242
|
+
|
|
243
|
+
task = asyncio.create_task(execute())
|
|
244
|
+
self.handlers.add(task)
|
|
245
|
+
|
|
246
|
+
def completed(future):
|
|
247
|
+
self.handlers.discard(future)
|
|
248
|
+
if not future.cancelled():
|
|
249
|
+
future.exception()
|
|
250
|
+
|
|
251
|
+
task.add_done_callback(completed)
|
|
252
|
+
return await asyncio.shield(task)
|
|
253
|
+
|
|
254
|
+
async def handle(self, request: Request):
|
|
255
|
+
epoch, apps = self.snapshot
|
|
256
|
+
key = request.headers.get("x-toolframe-resource")
|
|
257
|
+
reference = request.headers.get("x-toolframe-context")
|
|
258
|
+
app = apps.get(key)
|
|
259
|
+
if not reference or not app or self.closing:
|
|
260
|
+
return JSONResponse(
|
|
261
|
+
{
|
|
262
|
+
"error": {
|
|
263
|
+
"code": "forbidden",
|
|
264
|
+
"message": "Open this app through the Toolframe public URL",
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
status_code=403,
|
|
268
|
+
)
|
|
269
|
+
request_id = request.headers.get(
|
|
270
|
+
"x-toolframe-request-id", secrets.token_hex(12)
|
|
271
|
+
)
|
|
272
|
+
context = None
|
|
273
|
+
body = None
|
|
274
|
+
try:
|
|
275
|
+
if request.scope["path"].startswith("/_tf/assets/"):
|
|
276
|
+
return await deliver_asset(self, request, app, key, epoch, reference)
|
|
277
|
+
matches = [
|
|
278
|
+
(route, route.spec().match(request.scope["path"]))
|
|
279
|
+
for route in app.app.routes
|
|
280
|
+
]
|
|
281
|
+
matches = [
|
|
282
|
+
(route, params) for route, params in matches if params is not None
|
|
283
|
+
]
|
|
284
|
+
if not matches:
|
|
285
|
+
# Fallback remains within the selected app and never handles asset misses.
|
|
286
|
+
if (
|
|
287
|
+
app.catalog.spa_fallback
|
|
288
|
+
and request.method in {"GET", "HEAD"}
|
|
289
|
+
and "text/html" in request.headers.get("accept", "")
|
|
290
|
+
):
|
|
291
|
+
route = SimpleNamespace(
|
|
292
|
+
id="@spa",
|
|
293
|
+
spec=lambda: SimpleNamespace(allowed_methods={"GET", "HEAD"}),
|
|
294
|
+
handler=lambda request, ctx: WebResponse(
|
|
295
|
+
app.assets.content[app.catalog.spa_fallback],
|
|
296
|
+
headers={"Content-Type": "text/html; charset=utf-8"},
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
matches = [(route, {})]
|
|
300
|
+
else:
|
|
301
|
+
return JSONResponse(
|
|
302
|
+
{
|
|
303
|
+
"error": {
|
|
304
|
+
"code": "web_route_missing",
|
|
305
|
+
"message": "Unknown app route",
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
status_code=404,
|
|
309
|
+
)
|
|
310
|
+
selected = next(
|
|
311
|
+
(
|
|
312
|
+
(route, params)
|
|
313
|
+
for route, params in matches
|
|
314
|
+
if request.method in route.spec().allowed_methods
|
|
315
|
+
),
|
|
316
|
+
None,
|
|
317
|
+
)
|
|
318
|
+
if request.method == "OPTIONS":
|
|
319
|
+
selected = matches[0]
|
|
320
|
+
if selected is None:
|
|
321
|
+
return Response(
|
|
322
|
+
status_code=405,
|
|
323
|
+
headers={
|
|
324
|
+
"Allow": ", ".join(
|
|
325
|
+
sorted(
|
|
326
|
+
{
|
|
327
|
+
method
|
|
328
|
+
for route, _ in matches
|
|
329
|
+
for method in route.spec().allowed_methods
|
|
330
|
+
}
|
|
331
|
+
| {"OPTIONS"}
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
route, params = selected
|
|
337
|
+
settings = self.context.endpoints.get("web", {})
|
|
338
|
+
safe = request.method in {"GET", "HEAD", "OPTIONS"}
|
|
339
|
+
admission = await self.context.resource_admit(
|
|
340
|
+
key,
|
|
341
|
+
route.id,
|
|
342
|
+
epoch=epoch,
|
|
343
|
+
access_context=reference,
|
|
344
|
+
phase="complete" if safe else "authorize",
|
|
345
|
+
)
|
|
346
|
+
body = RequestBody(request, settings.get("max_body_bytes", 8 * 1024 * 1024))
|
|
347
|
+
await body.read()
|
|
348
|
+
if not safe:
|
|
349
|
+
csrf = request.headers.get("x-csrf-token", "")
|
|
350
|
+
revision = request.headers.get("x-web-revision", "")
|
|
351
|
+
if body.media_type in FORM_TYPES:
|
|
352
|
+
values = await body.form()
|
|
353
|
+
csrf = csrf or values.get("_tf_csrf", "")
|
|
354
|
+
revision = revision or values.get("_tf_revision", "")
|
|
355
|
+
admission = await self.context.resource_admit(
|
|
356
|
+
key,
|
|
357
|
+
route.id,
|
|
358
|
+
epoch=epoch,
|
|
359
|
+
access_context=reference,
|
|
360
|
+
csrf=csrf,
|
|
361
|
+
revision=revision,
|
|
362
|
+
)
|
|
363
|
+
if request.method == "OPTIONS":
|
|
364
|
+
return Response(
|
|
365
|
+
status_code=204,
|
|
366
|
+
headers={
|
|
367
|
+
"Allow": ", ".join(
|
|
368
|
+
sorted(
|
|
369
|
+
{
|
|
370
|
+
method
|
|
371
|
+
for item, _ in matches
|
|
372
|
+
for method in item.spec().allowed_methods
|
|
373
|
+
}
|
|
374
|
+
| {"OPTIONS"}
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
context = WebContext(
|
|
380
|
+
self.context, app.resource, epoch, reference, admission, app.catalog
|
|
381
|
+
)
|
|
382
|
+
context.nonce = secrets.token_urlsafe(24)
|
|
383
|
+
context._assets, context._templates = app.assets, app.templates
|
|
384
|
+
value = WebRequest(
|
|
385
|
+
request.method,
|
|
386
|
+
request.scope["path"],
|
|
387
|
+
MappingProxyType(
|
|
388
|
+
{
|
|
389
|
+
key: tuple(values)
|
|
390
|
+
for key, values in parse_qs(
|
|
391
|
+
request.scope.get("query_string", b"").decode("ascii"),
|
|
392
|
+
keep_blank_values=True,
|
|
393
|
+
).items()
|
|
394
|
+
}
|
|
395
|
+
),
|
|
396
|
+
MappingProxyType(params),
|
|
397
|
+
MappingProxyType(
|
|
398
|
+
{
|
|
399
|
+
key: value
|
|
400
|
+
for key, value in request.headers.items()
|
|
401
|
+
if key not in {"cookie", "authorization"}
|
|
402
|
+
and not key.startswith("x-toolframe-")
|
|
403
|
+
}
|
|
404
|
+
),
|
|
405
|
+
body.content,
|
|
406
|
+
body.form,
|
|
407
|
+
)
|
|
408
|
+
async with asyncio.timeout(settings.get("request_timeout", 30)):
|
|
409
|
+
if inspect.iscoroutinefunction(route.handler):
|
|
410
|
+
result = await route.handler(value, context)
|
|
411
|
+
else:
|
|
412
|
+
result = await self.run_sync(route.handler, value, context)
|
|
413
|
+
if not isinstance(result, WebResponse):
|
|
414
|
+
raise TypeError("Web handler must return WebResponse")
|
|
415
|
+
response_body = (
|
|
416
|
+
result.body.encode() if isinstance(result.body, str) else result.body
|
|
417
|
+
)
|
|
418
|
+
if len(response_body) > 8 * 1024 * 1024:
|
|
419
|
+
raise ValueError("Web response exceeds 8 MiB")
|
|
420
|
+
headers = {
|
|
421
|
+
key: value
|
|
422
|
+
for key, value in result.headers.items()
|
|
423
|
+
if key.lower()
|
|
424
|
+
not in {
|
|
425
|
+
"content-security-policy",
|
|
426
|
+
"content-length",
|
|
427
|
+
"transfer-encoding",
|
|
428
|
+
"connection",
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
headers.update(
|
|
432
|
+
{
|
|
433
|
+
"Content-Security-Policy": application_policy(
|
|
434
|
+
app.catalog, nonce=context.nonce
|
|
435
|
+
),
|
|
436
|
+
"Cache-Control": "private, no-store",
|
|
437
|
+
"Vary": "HX-Request, Accept",
|
|
438
|
+
"X-Web-Revision": context.revision,
|
|
439
|
+
"X-Content-Type-Options": "nosniff",
|
|
440
|
+
"Referrer-Policy": "same-origin",
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
return Response(
|
|
444
|
+
b"" if request.method == "HEAD" else response_body,
|
|
445
|
+
status_code=result.status,
|
|
446
|
+
headers=headers,
|
|
447
|
+
)
|
|
448
|
+
except PluginError as error:
|
|
449
|
+
return JSONResponse(
|
|
450
|
+
{
|
|
451
|
+
"error": {"code": error.code, "message": error.message},
|
|
452
|
+
"login_url": "/_toolframe/auth/login",
|
|
453
|
+
"request_id": request_id,
|
|
454
|
+
},
|
|
455
|
+
status_code=ERROR_STATUS.get(error.code, 400),
|
|
456
|
+
headers={"Cache-Control": "no-store"},
|
|
457
|
+
)
|
|
458
|
+
except TimeoutError:
|
|
459
|
+
return JSONResponse(
|
|
460
|
+
{
|
|
461
|
+
"error": {
|
|
462
|
+
"code": "web_timeout",
|
|
463
|
+
"message": "Web request timed out",
|
|
464
|
+
},
|
|
465
|
+
"request_id": request_id,
|
|
466
|
+
},
|
|
467
|
+
status_code=504,
|
|
468
|
+
)
|
|
469
|
+
except HTTPException:
|
|
470
|
+
return JSONResponse(
|
|
471
|
+
{
|
|
472
|
+
"error": {
|
|
473
|
+
"code": "invalid_request",
|
|
474
|
+
"message": "Invalid form encoding or field limits",
|
|
475
|
+
},
|
|
476
|
+
"request_id": request_id,
|
|
477
|
+
},
|
|
478
|
+
status_code=422,
|
|
479
|
+
)
|
|
480
|
+
except Exception as error: # noqa: BLE001 - never expose handler errors or filesystem paths over HTTP
|
|
481
|
+
self.context.log.error(
|
|
482
|
+
"Web request failed",
|
|
483
|
+
extra={
|
|
484
|
+
"error.type": type(error).__name__,
|
|
485
|
+
"toolframe.web.request_id": request_id,
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
return JSONResponse(
|
|
489
|
+
{
|
|
490
|
+
"error": {"code": "web_error", "message": "Web request failed"},
|
|
491
|
+
"request_id": request_id,
|
|
492
|
+
},
|
|
493
|
+
status_code=500,
|
|
494
|
+
)
|
|
495
|
+
finally:
|
|
496
|
+
if context:
|
|
497
|
+
context.close()
|
|
498
|
+
if body is not None:
|
|
499
|
+
await body.close()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
plugin = PluginDefinition(
|
|
503
|
+
id="web",
|
|
504
|
+
kind="provider",
|
|
505
|
+
provider_kind="web",
|
|
506
|
+
factory="toolframe_web.plugin:WebProvider",
|
|
507
|
+
authorization=AuthorizationSpec(),
|
|
508
|
+
)
|
toolframe_web/policy.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Response CSP derived exclusively from the validated application catalog."""
|
|
2
|
+
|
|
3
|
+
RESTRICTED_ASSET_POLICY = "default-src 'none'; frame-ancestors 'none'; sandbox"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def application_policy(catalog, *, nonce=None):
|
|
7
|
+
policy = {
|
|
8
|
+
"default-src": ("'none'",),
|
|
9
|
+
"script-src": ("'self'",),
|
|
10
|
+
"style-src": ("'self'",),
|
|
11
|
+
"img-src": ("'self'", "data:"),
|
|
12
|
+
"font-src": ("'self'",),
|
|
13
|
+
"connect-src": ("'self'",),
|
|
14
|
+
"worker-src": ("'none'",),
|
|
15
|
+
"base-uri": ("'none'",),
|
|
16
|
+
"object-src": ("'none'",),
|
|
17
|
+
"form-action": ("'self'",),
|
|
18
|
+
"frame-ancestors": ("'none'",),
|
|
19
|
+
**dict(catalog.csp),
|
|
20
|
+
}
|
|
21
|
+
if nonce and "'none'" not in policy["script-src"]:
|
|
22
|
+
policy["script-src"] = (*policy["script-src"], f"'nonce-{nonce}'")
|
|
23
|
+
return "; ".join(key + " " + " ".join(values) for key, values in policy.items())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def asset_policy(catalog, item):
|
|
27
|
+
# Workers use their script response's CSP. A stable policy for every script
|
|
28
|
+
# avoids Sec-Fetch-Dest cache variants; HTML/SVG stay sandboxed.
|
|
29
|
+
if item.media_type == "text/javascript":
|
|
30
|
+
return application_policy(catalog)
|
|
31
|
+
return RESTRICTED_ASSET_POLICY
|