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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Authorized delivery of current and retained sealed assets."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
|
|
6
|
+
from starlette.responses import Response
|
|
7
|
+
from toolframe_sdk import PluginError
|
|
8
|
+
|
|
9
|
+
from .assets import AssetSet
|
|
10
|
+
from .policy import asset_policy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def deliver_asset(provider, request, app, key, epoch, reference):
|
|
14
|
+
asset = request.scope["path"].removeprefix("/_tf/assets/")
|
|
15
|
+
admission = await provider.context.resource_admit(
|
|
16
|
+
key, None, epoch=epoch, access_context=reference, asset=asset
|
|
17
|
+
)
|
|
18
|
+
checksum, _, path = asset.partition("/")
|
|
19
|
+
assets = app.assets
|
|
20
|
+
if checksum != assets.manifest.digest:
|
|
21
|
+
if provider.cache is None:
|
|
22
|
+
raise PluginError("web_asset_missing", "Previous asset set is unavailable")
|
|
23
|
+
assets = await asyncio.to_thread(AssetSet.cached, provider.cache, checksum)
|
|
24
|
+
item = next((file for file in assets.manifest.files if file.path == path), None)
|
|
25
|
+
if item is None:
|
|
26
|
+
raise PluginError("web_asset_missing", "Asset is not declared")
|
|
27
|
+
policy = asset_policy(app.catalog, item)
|
|
28
|
+
# Capabilities may change without changing asset bytes. Revalidate scripts
|
|
29
|
+
# with a validator that includes the current policy, including retained sets.
|
|
30
|
+
script = item.media_type == "text/javascript"
|
|
31
|
+
etag = '"' + hashlib.sha256((item.sha256 + policy).encode()).hexdigest() + '"'
|
|
32
|
+
cache_control = "private, no-store"
|
|
33
|
+
if admission["visibility"] == "public":
|
|
34
|
+
cache_control = (
|
|
35
|
+
"public, max-age=0, must-revalidate"
|
|
36
|
+
if script
|
|
37
|
+
else "public, max-age=31536000, immutable"
|
|
38
|
+
)
|
|
39
|
+
headers = {
|
|
40
|
+
"ETag": etag,
|
|
41
|
+
"X-Content-Type-Options": "nosniff",
|
|
42
|
+
"Cache-Control": cache_control,
|
|
43
|
+
"Content-Security-Policy": policy,
|
|
44
|
+
}
|
|
45
|
+
if request.headers.get("if-none-match") == etag:
|
|
46
|
+
return Response(status_code=304, headers=headers)
|
|
47
|
+
return Response(
|
|
48
|
+
b"" if request.method == "HEAD" else assets.content[path],
|
|
49
|
+
media_type=item.media_type,
|
|
50
|
+
headers=headers,
|
|
51
|
+
)
|
toolframe_web/assets.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Sealed, allowlisted browser asset sets. Runtime never downloads or builds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.resources
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, field_validator, model_validator
|
|
11
|
+
from toolframe_sdk import PluginError, digest
|
|
12
|
+
from toolframe_sdk.contracts import Contract
|
|
13
|
+
from toolframe_sdk.web import relative_file
|
|
14
|
+
|
|
15
|
+
MEDIA_TYPES = {
|
|
16
|
+
".js": "text/javascript",
|
|
17
|
+
".mjs": "text/javascript",
|
|
18
|
+
".css": "text/css",
|
|
19
|
+
".html": "text/html",
|
|
20
|
+
".json": "application/json",
|
|
21
|
+
".map": "application/json",
|
|
22
|
+
".wasm": "application/wasm",
|
|
23
|
+
".woff": "font/woff",
|
|
24
|
+
".woff2": "font/woff2",
|
|
25
|
+
".ttf": "font/ttf",
|
|
26
|
+
".otf": "font/otf",
|
|
27
|
+
".svg": "image/svg+xml",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".webp": "image/webp",
|
|
32
|
+
".gif": "image/gif",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
".txt": "text/plain",
|
|
35
|
+
}
|
|
36
|
+
MAX_FILE = 8 * 1024 * 1024
|
|
37
|
+
MAX_SET = 32 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def media_type(path):
|
|
41
|
+
try:
|
|
42
|
+
return MEDIA_TYPES[Path(path).suffix.lower()]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise ValueError(f"Unsupported public asset extension: {path}") from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AssetFile(Contract):
|
|
48
|
+
path: str
|
|
49
|
+
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
50
|
+
size: int = Field(ge=0, le=MAX_FILE)
|
|
51
|
+
media_type: str
|
|
52
|
+
source: str = "local"
|
|
53
|
+
license: str | None = None
|
|
54
|
+
dependencies: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def paths(self):
|
|
58
|
+
relative_file(self.path)
|
|
59
|
+
if self.media_type != media_type(self.path):
|
|
60
|
+
raise ValueError("Asset MIME type must match its declared file extension")
|
|
61
|
+
for value in self.dependencies:
|
|
62
|
+
relative_file(value)
|
|
63
|
+
if self.license:
|
|
64
|
+
relative_file(self.license)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Document(Contract):
|
|
69
|
+
styles: tuple[str, ...] = ()
|
|
70
|
+
scripts: tuple[str, ...] = ()
|
|
71
|
+
modules: tuple[str, ...] = ()
|
|
72
|
+
imports: dict[str, str] = Field(default_factory=dict)
|
|
73
|
+
visibility: str = "inherit"
|
|
74
|
+
|
|
75
|
+
@field_validator("visibility")
|
|
76
|
+
@classmethod
|
|
77
|
+
def visibility_value(cls, value):
|
|
78
|
+
if value not in {"inherit", "public"}:
|
|
79
|
+
raise ValueError("Asset visibility must be inherit or public")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Manifest(Contract):
|
|
84
|
+
schema_version: int = Field(default=1, ge=1, le=1)
|
|
85
|
+
public: str = "web/public"
|
|
86
|
+
document: Document = Field(default_factory=Document)
|
|
87
|
+
files: tuple[AssetFile, ...]
|
|
88
|
+
recipe: dict = Field(default_factory=dict)
|
|
89
|
+
digest: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def sealed(self):
|
|
93
|
+
relative_file(self.public)
|
|
94
|
+
paths = {item.path for item in self.files}
|
|
95
|
+
if (
|
|
96
|
+
len(self.files) > 2048
|
|
97
|
+
or len(paths) != len(self.files)
|
|
98
|
+
or len({path.casefold() for path in paths}) != len(paths)
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("Asset paths must be distinct and bounded")
|
|
101
|
+
if sum(item.size for item in self.files) > MAX_SET:
|
|
102
|
+
raise ValueError("Asset set exceeds 32 MiB")
|
|
103
|
+
referenced = {
|
|
104
|
+
*self.document.styles,
|
|
105
|
+
*self.document.scripts,
|
|
106
|
+
*self.document.modules,
|
|
107
|
+
*self.document.imports.values(),
|
|
108
|
+
}
|
|
109
|
+
referenced |= {path for item in self.files for path in item.dependencies}
|
|
110
|
+
referenced |= {item.license for item in self.files if item.license}
|
|
111
|
+
if not referenced <= paths:
|
|
112
|
+
raise ValueError("Manifest references an undeclared asset or license")
|
|
113
|
+
if any(media_type(path) != "text/css" for path in self.document.styles):
|
|
114
|
+
raise ValueError("Style entries must be CSS")
|
|
115
|
+
if any(
|
|
116
|
+
media_type(path) != "text/javascript"
|
|
117
|
+
for path in (
|
|
118
|
+
*self.document.scripts,
|
|
119
|
+
*self.document.modules,
|
|
120
|
+
*self.document.imports.values(),
|
|
121
|
+
)
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("Script and import entries must be JavaScript")
|
|
124
|
+
if self.digest != digest(self.model_dump(mode="json", exclude={"digest"})):
|
|
125
|
+
raise ValueError("Asset manifest digest mismatch")
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def seal(public, files, *, document=None, recipe=None):
|
|
130
|
+
payload = {
|
|
131
|
+
"schema_version": 1,
|
|
132
|
+
"public": public,
|
|
133
|
+
"document": (document or Document()).model_dump(mode="json"),
|
|
134
|
+
"files": [
|
|
135
|
+
item.model_dump(mode="json")
|
|
136
|
+
for item in sorted(files, key=lambda item: item.path)
|
|
137
|
+
],
|
|
138
|
+
"recipe": recipe or {},
|
|
139
|
+
}
|
|
140
|
+
return Manifest(**payload, digest=digest(payload))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def safe_file(root, relative):
|
|
144
|
+
relative_file(relative)
|
|
145
|
+
root = Path(root).resolve()
|
|
146
|
+
path = root.joinpath(relative)
|
|
147
|
+
if not path.resolve().is_relative_to(root) or any(
|
|
148
|
+
parent.is_symlink()
|
|
149
|
+
for parent in (path, *path.parents)
|
|
150
|
+
if parent != root and parent.is_relative_to(root)
|
|
151
|
+
):
|
|
152
|
+
raise ValueError("Assets cannot contain symlinks or escape their package")
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class AssetSet:
|
|
158
|
+
manifest: Manifest
|
|
159
|
+
content: dict
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def load(cls, package, manifest_path):
|
|
163
|
+
root = Path(str(importlib.resources.files(package)))
|
|
164
|
+
path = safe_file(root, manifest_path)
|
|
165
|
+
if path.stat().st_size > 1024 * 1024:
|
|
166
|
+
raise ValueError("Asset manifest exceeds 1 MiB")
|
|
167
|
+
manifest = Manifest.model_validate_json(path.read_bytes())
|
|
168
|
+
content = {}
|
|
169
|
+
for item in manifest.files:
|
|
170
|
+
file = safe_file(root, manifest.public + "/" + item.path)
|
|
171
|
+
if file.stat().st_size != item.size:
|
|
172
|
+
raise ValueError("Asset size mismatch")
|
|
173
|
+
value = file.read_bytes()
|
|
174
|
+
if hashlib.sha256(value).hexdigest() != item.sha256:
|
|
175
|
+
raise ValueError("Asset content hash mismatch")
|
|
176
|
+
content[item.path] = value
|
|
177
|
+
return cls(manifest, content)
|
|
178
|
+
|
|
179
|
+
def persist(self, root):
|
|
180
|
+
target = Path(root) / self.manifest.digest
|
|
181
|
+
target.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
182
|
+
for item in self.manifest.files:
|
|
183
|
+
path = safe_file(target, "public/" + item.path)
|
|
184
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
path.write_bytes(self.content[item.path])
|
|
186
|
+
(target / "manifest.json").write_text(self.manifest.model_dump_json())
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def cached(cls, root, checksum):
|
|
190
|
+
if len(checksum) != 64 or any(c not in "0123456789abcdef" for c in checksum):
|
|
191
|
+
raise PluginError("web_asset_missing", "Unknown Web asset set")
|
|
192
|
+
target = Path(root) / checksum
|
|
193
|
+
manifest = Manifest.model_validate_json((target / "manifest.json").read_bytes())
|
|
194
|
+
if manifest.digest != checksum:
|
|
195
|
+
raise ValueError("Cached asset identity mismatch")
|
|
196
|
+
content = {}
|
|
197
|
+
for item in manifest.files:
|
|
198
|
+
path = safe_file(target, "public/" + item.path)
|
|
199
|
+
if path.stat().st_size != item.size:
|
|
200
|
+
raise ValueError("Cached asset size mismatch")
|
|
201
|
+
value = path.read_bytes()
|
|
202
|
+
if hashlib.sha256(value).hexdigest() != item.sha256:
|
|
203
|
+
raise ValueError("Cached asset hash mismatch")
|
|
204
|
+
content[item.path] = value
|
|
205
|
+
return cls(manifest, content)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Distribution authoring entries, independent of runtime plugin activation."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
|
|
6
|
+
import tomlkit
|
|
7
|
+
from toolframe_sdk.authoring import render_plugin, write_template
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate(*, destination, parameters):
|
|
11
|
+
rendered = render_plugin(
|
|
12
|
+
files("toolframe_web").joinpath("templates/notes"),
|
|
13
|
+
parameters["name"],
|
|
14
|
+
text_suffixes=(".toml", ".html", ".js", ".md"),
|
|
15
|
+
)
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
project = tomlkit.parse(rendered[Path("pyproject.toml")].decode())
|
|
19
|
+
requirement = "toolframe-web==" + version("toolframe-web")
|
|
20
|
+
project["project"]["optional-dependencies"]["web"] = [requirement]
|
|
21
|
+
project["tool"]["toolframe"] = {"authoring": {"source": "pypi:" + requirement}}
|
|
22
|
+
rendered[Path("pyproject.toml")] = tomlkit.dumps(project).encode()
|
|
23
|
+
write_template(destination, rendered)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prepare(*, source, destination, cache, offline):
|
|
27
|
+
from .prepare import stage
|
|
28
|
+
|
|
29
|
+
return stage(source, destination, cache, offline=offline)
|
toolframe_web/cli.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Private build adapter and standalone author-time staging command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .prepare import stage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
parser = argparse.ArgumentParser(prog="toolframe-web")
|
|
13
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
prepare = commands.add_parser("stage")
|
|
15
|
+
prepare.add_argument("source", type=Path)
|
|
16
|
+
prepare.add_argument("destination", type=Path)
|
|
17
|
+
prepare.add_argument("--cache", type=Path, required=True)
|
|
18
|
+
prepare.add_argument("--offline", action="store_true")
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
try:
|
|
21
|
+
print(
|
|
22
|
+
json.dumps(
|
|
23
|
+
stage(args.source, args.destination, args.cache, offline=args.offline)
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
except (OSError, ValueError) as error:
|
|
27
|
+
print(json.dumps({"error": str(error)}), file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
return 0
|