htmlship 0.1.4__tar.gz → 0.2.0__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.
Files changed (34) hide show
  1. htmlship-0.2.0/LICENSE +29 -0
  2. {htmlship-0.1.4 → htmlship-0.2.0}/PKG-INFO +49 -5
  3. {htmlship-0.1.4 → htmlship-0.2.0}/README.md +47 -4
  4. {htmlship-0.1.4 → htmlship-0.2.0}/pyproject.toml +1 -1
  5. htmlship-0.2.0/src/htmlship/_version.py +1 -0
  6. htmlship-0.2.0/src/htmlship/build.py +325 -0
  7. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/cli.py +114 -11
  8. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/client.py +42 -0
  9. htmlship-0.2.0/src/htmlship_mcp/__init__.py +1 -0
  10. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_mcp/server.py +56 -3
  11. htmlship-0.2.0/src/htmlship_server/__init__.py +1 -0
  12. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/main.py +20 -1
  13. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/middleware.py +4 -1
  14. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/view.py +47 -10
  15. htmlship-0.1.4/src/htmlship/_version.py +0 -1
  16. htmlship-0.1.4/src/htmlship_mcp/__init__.py +0 -1
  17. htmlship-0.1.4/src/htmlship_server/__init__.py +0 -1
  18. {htmlship-0.1.4 → htmlship-0.2.0}/.gitignore +0 -0
  19. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/__init__.py +0 -0
  20. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/exceptions.py +0 -0
  21. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/models.py +0 -0
  22. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/config.py +0 -0
  23. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/database.py +0 -0
  24. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/db_models/__init__.py +0 -0
  25. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/db_models/page.py +0 -0
  26. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/exceptions.py +0 -0
  27. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/__init__.py +0 -0
  28. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/meta.py +0 -0
  29. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/pages.py +0 -0
  30. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/schemas/__init__.py +0 -0
  31. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/schemas/pages.py +0 -0
  32. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/security.py +0 -0
  33. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/slugs.py +0 -0
  34. {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/storage.py +0 -0
htmlship-0.2.0/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HTMLShip
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The MIT license above applies to the source code in this repository.
26
+
27
+ It does NOT cover the "HTMLShip" name or the brand assets in `web/assets/`
28
+ (logo, favicons, and any derived artwork). Those are reserved — see
29
+ `web/assets/LICENSE` for details.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlship
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Host and share HTML pages from LLMs and coding agents in one line.
5
5
  Project-URL: Homepage, https://htmlship.com
6
6
  Project-URL: Repository, https://github.com/htmlship/htmlship
7
7
  Author: HTMLShip
8
8
  License: MIT
9
+ License-File: LICENSE
9
10
  Keywords: agent,hosting,html,llm,mcp,paste
10
11
  Classifier: Development Status :: 3 - Alpha
11
12
  Classifier: Intended Audience :: Developers
@@ -58,6 +59,9 @@ HTMLShip has four surfaces:
58
59
  npx htmlship publish report.html
59
60
  npx htmlship publish report.html --password "demo-pass"
60
61
 
62
+ # Build a frontend project (React/Vite/etc.) and ship the compiled app
63
+ npx htmlship deploy ./my-app
64
+
61
65
  # Python
62
66
  pip install htmlship
63
67
  ```
@@ -128,7 +132,36 @@ npx htmlship publish report.html --password "demo-pass"
128
132
  npx htmlship list-mine
129
133
  ```
130
134
 
131
- The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
135
+ The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. When a page's key isn't saved there and `--owner-key` isn't passed, `update` and `delete` prompt for it interactively — `delete` asks for the key right after the `[y/N]` confirmation — so the public slug alone is never enough to mutate a page. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
136
+
137
+ ## Deploy built apps
138
+
139
+ `deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
140
+
141
+ ```bash
142
+ htmlship deploy ./my-app # detect build script, build, inline, publish
143
+ htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
144
+ htmlship deploy --build-cmd "vite build" --out dist
145
+ npx htmlship deploy ./my-app # same, no install
146
+ ```
147
+
148
+ How it works:
149
+
150
+ 1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
151
+ 2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be ≤ 10 MB.
152
+ 3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
153
+
154
+ The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
155
+
156
+ ```python
157
+ import htmlship
158
+
159
+ # Library: build ./my-app locally and publish the compiled app
160
+ page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
161
+ print(page.url)
162
+ ```
163
+
164
+ **Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
132
165
 
133
166
  ## API
134
167
 
@@ -162,12 +195,14 @@ Notes:
162
195
  - `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
163
196
  - Payloads are limited to 10 MiB by default.
164
197
  - `expires_in` is in **minutes** and must be between 1 and 10080 (7 days). Requests above the cap are rejected with `422`.
165
- - `sandbox_mode` accepts `strict` or `relaxed`; the current view headers use the strict CSP.
198
+ - `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
166
199
  - Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
167
200
 
201
+ There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
202
+
168
203
  ## Rendering
169
204
 
170
- Rendered HTML is served from `view.htmlship.com/{slug}` with strict security headers:
205
+ Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the default) get a CSP that blocks all scripts:
171
206
 
172
207
  - `Content-Security-Policy: default-src 'none'`
173
208
  - images from `data:` and `https:`
@@ -177,6 +212,8 @@ Rendered HTML is served from `view.htmlship.com/{slug}` with strict security hea
177
212
  - `Referrer-Policy: no-referrer`
178
213
  - restrictive `Permissions-Policy`
179
214
 
215
+ **Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
216
+
180
217
  The app routes by `Host` header:
181
218
 
182
219
  - `htmlship.com` serves the landing page
@@ -191,9 +228,10 @@ curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
191
228
 
192
229
  ## MCP Server
193
230
 
194
- HTMLShip ships a stdio MCP server with three tools:
231
+ HTMLShip ships a stdio MCP server with four tools:
195
232
 
196
233
  - `publish_html` (accepts optional `password`)
234
+ - `deploy_project` — build a local frontend project and publish the compiled app (runs the build on the machine hosting the MCP server)
197
235
  - `fetch_html`
198
236
  - `update_html`
199
237
 
@@ -319,3 +357,9 @@ scripts/ Deploy and smoke-test scripts
319
357
  ```
320
358
 
321
359
  The npm package mirrors the Python CLI surface and reads/writes the same `~/.htmlship/keys.json` file format, so users can mix and match. The Node MCP server lives at `htmlship mcp` (subcommand) rather than a separate `htmlship-mcp` bin.
360
+
361
+ ## License
362
+
363
+ Source code in this repository is licensed under the MIT License — see [`LICENSE`](./LICENSE).
364
+
365
+ The "HTMLShip" name and the brand assets in `web/assets/` (logo, favicons) are not covered by MIT and are reserved — see [`web/assets/LICENSE`](./web/assets/LICENSE). If you fork to run your own instance, please replace those files with your own branding.
@@ -14,6 +14,9 @@ HTMLShip has four surfaces:
14
14
  npx htmlship publish report.html
15
15
  npx htmlship publish report.html --password "demo-pass"
16
16
 
17
+ # Build a frontend project (React/Vite/etc.) and ship the compiled app
18
+ npx htmlship deploy ./my-app
19
+
17
20
  # Python
18
21
  pip install htmlship
19
22
  ```
@@ -84,7 +87,36 @@ npx htmlship publish report.html --password "demo-pass"
84
87
  npx htmlship list-mine
85
88
  ```
86
89
 
87
- The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
90
+ The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. When a page's key isn't saved there and `--owner-key` isn't passed, `update` and `delete` prompt for it interactively — `delete` asks for the key right after the `[y/N]` confirmation — so the public slug alone is never enough to mutate a page. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
91
+
92
+ ## Deploy built apps
93
+
94
+ `deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
95
+
96
+ ```bash
97
+ htmlship deploy ./my-app # detect build script, build, inline, publish
98
+ htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
99
+ htmlship deploy --build-cmd "vite build" --out dist
100
+ npx htmlship deploy ./my-app # same, no install
101
+ ```
102
+
103
+ How it works:
104
+
105
+ 1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
106
+ 2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be ≤ 10 MB.
107
+ 3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
108
+
109
+ The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
110
+
111
+ ```python
112
+ import htmlship
113
+
114
+ # Library: build ./my-app locally and publish the compiled app
115
+ page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
116
+ print(page.url)
117
+ ```
118
+
119
+ **Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
88
120
 
89
121
  ## API
90
122
 
@@ -118,12 +150,14 @@ Notes:
118
150
  - `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
119
151
  - Payloads are limited to 10 MiB by default.
120
152
  - `expires_in` is in **minutes** and must be between 1 and 10080 (7 days). Requests above the cap are rejected with `422`.
121
- - `sandbox_mode` accepts `strict` or `relaxed`; the current view headers use the strict CSP.
153
+ - `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
122
154
  - Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
123
155
 
156
+ There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
157
+
124
158
  ## Rendering
125
159
 
126
- Rendered HTML is served from `view.htmlship.com/{slug}` with strict security headers:
160
+ Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the default) get a CSP that blocks all scripts:
127
161
 
128
162
  - `Content-Security-Policy: default-src 'none'`
129
163
  - images from `data:` and `https:`
@@ -133,6 +167,8 @@ Rendered HTML is served from `view.htmlship.com/{slug}` with strict security hea
133
167
  - `Referrer-Policy: no-referrer`
134
168
  - restrictive `Permissions-Policy`
135
169
 
170
+ **Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
171
+
136
172
  The app routes by `Host` header:
137
173
 
138
174
  - `htmlship.com` serves the landing page
@@ -147,9 +183,10 @@ curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
147
183
 
148
184
  ## MCP Server
149
185
 
150
- HTMLShip ships a stdio MCP server with three tools:
186
+ HTMLShip ships a stdio MCP server with four tools:
151
187
 
152
188
  - `publish_html` (accepts optional `password`)
189
+ - `deploy_project` — build a local frontend project and publish the compiled app (runs the build on the machine hosting the MCP server)
153
190
  - `fetch_html`
154
191
  - `update_html`
155
192
 
@@ -275,3 +312,9 @@ scripts/ Deploy and smoke-test scripts
275
312
  ```
276
313
 
277
314
  The npm package mirrors the Python CLI surface and reads/writes the same `~/.htmlship/keys.json` file format, so users can mix and match. The Node MCP server lives at `htmlship mcp` (subcommand) rather than a separate `htmlship-mcp` bin.
315
+
316
+ ## License
317
+
318
+ Source code in this repository is licensed under the MIT License — see [`LICENSE`](./LICENSE).
319
+
320
+ The "HTMLShip" name and the brand assets in `web/assets/` (logo, favicons) are not covered by MIT and are reserved — see [`web/assets/LICENSE`](./web/assets/LICENSE). If you fork to run your own instance, please replace those files with your own branding.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlship"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "Host and share HTML pages from LLMs and coding agents in one line."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,325 @@
1
+ """Client-side build + single-file inliner for ``htmlship deploy``.
2
+
3
+ Mirrors the TypeScript implementation in ``npm/src/build.ts``: detect a frontend
4
+ project, run its build *locally* (never on the server), and inline the output
5
+ into one self-contained HTML document suitable for publishing as a relaxed
6
+ (sandboxed-script) page.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ import re
13
+ import subprocess
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from urllib.parse import unquote
18
+
19
+ from .exceptions import HTMLShipError
20
+
21
+ # Order matters: the first lockfile found wins.
22
+ _LOCKFILES: list[tuple[str, str]] = [
23
+ ("pnpm-lock.yaml", "pnpm"),
24
+ ("yarn.lock", "yarn"),
25
+ ("bun.lockb", "bun"),
26
+ ("bun.lock", "bun"),
27
+ ("package-lock.json", "npm"),
28
+ ]
29
+
30
+ DEFAULT_BUILD_TIMEOUT = 5 * 60
31
+ MAX_PAYLOAD_BYTES = 10 * 1024 * 1024
32
+
33
+ LogFn = Callable[[str], None]
34
+
35
+
36
+ def _noop(_msg: str) -> None: # pragma: no cover - trivial
37
+ return None
38
+
39
+
40
+ @dataclass
41
+ class Project:
42
+ dir: Path
43
+ build_script: str # empty when build_cmd overrides it
44
+ package_manager: str
45
+
46
+
47
+ def detect_package_manager(directory: Path) -> str:
48
+ for fname, pm in _LOCKFILES:
49
+ if (directory / fname).exists():
50
+ return pm
51
+ return "npm"
52
+
53
+
54
+ def detect_project(directory: Path, build_cmd_override: str | None = None) -> Project:
55
+ pkg_path = directory / "package.json"
56
+ if not pkg_path.exists():
57
+ raise HTMLShipError(f"no package.json found in {directory}")
58
+ try:
59
+ pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
60
+ except Exception as exc:
61
+ raise HTMLShipError(f"failed to read package.json: {exc}") from exc
62
+
63
+ scripts = pkg.get("scripts") or {} if isinstance(pkg, dict) else {}
64
+ pm = detect_package_manager(directory)
65
+ if build_cmd_override:
66
+ return Project(dir=directory, build_script="", package_manager=pm)
67
+
68
+ if scripts.get("build"):
69
+ build_script = "build"
70
+ elif scripts.get("build:prod"):
71
+ build_script = "build:prod"
72
+ else:
73
+ raise HTMLShipError(
74
+ 'no "build" or "build:prod" script in package.json (use build_cmd to specify one)'
75
+ )
76
+ return Project(dir=directory, build_script=build_script, package_manager=pm)
77
+
78
+
79
+ def build_command(pm: str, script: str) -> str:
80
+ # npm/pnpm/bun use `run <script>`; yarn runs the script directly.
81
+ return f"yarn {script}" if pm == "yarn" else f"{pm} run {script}"
82
+
83
+
84
+ def install_command(pm: str) -> str:
85
+ return f"{pm} install"
86
+
87
+
88
+ def run_build(
89
+ project: Project,
90
+ *,
91
+ install: bool = False,
92
+ build_cmd: str | None = None,
93
+ timeout: int = DEFAULT_BUILD_TIMEOUT,
94
+ log: LogFn = _noop,
95
+ ) -> None:
96
+ if install:
97
+ _exec(install_command(project.package_manager), project.dir, timeout, log)
98
+ cmd = build_cmd or build_command(project.package_manager, project.build_script)
99
+ _exec(cmd, project.dir, timeout, log)
100
+
101
+
102
+ def _exec(command: str, cwd: Path, timeout: int, log: LogFn) -> None:
103
+ log(f"$ {command}")
104
+ # shell=True keeps this cross-platform and lets build_cmd accept a normal
105
+ # command line. The command is either a fixed package-manager invocation or
106
+ # the user's own explicit override — never remote/untrusted input — and runs
107
+ # only on the user's machine, by design.
108
+ try:
109
+ result = subprocess.run(command, cwd=str(cwd), shell=True, timeout=timeout, check=False) # noqa: S602
110
+ except FileNotFoundError as exc:
111
+ raise HTMLShipError(f"build failed: `{command}` — command not found") from exc
112
+ except subprocess.TimeoutExpired as exc:
113
+ raise HTMLShipError(f"build timed out after {timeout}s: `{command}`") from exc
114
+ if result.returncode != 0:
115
+ raise HTMLShipError(f"build exited with code {result.returncode}: `{command}`")
116
+
117
+
118
+ def resolve_output_dir(directory: Path, override: str | None = None) -> Path:
119
+ candidates = [override] if override else ["dist", "build"]
120
+ for c in candidates:
121
+ assert c is not None
122
+ p = Path(c) if Path(c).is_absolute() else directory / c
123
+ if p.is_dir():
124
+ return p
125
+ if override:
126
+ raise HTMLShipError(f"build output dir not found: {override}")
127
+ raise HTMLShipError("no build output found (looked for dist/ and build/; use out to specify)")
128
+
129
+
130
+ # --- single-file inliner ----------------------------------------------------
131
+
132
+ _MIME = {
133
+ ".png": "image/png",
134
+ ".jpg": "image/jpeg",
135
+ ".jpeg": "image/jpeg",
136
+ ".gif": "image/gif",
137
+ ".svg": "image/svg+xml",
138
+ ".webp": "image/webp",
139
+ ".avif": "image/avif",
140
+ ".ico": "image/x-icon",
141
+ ".woff": "font/woff",
142
+ ".woff2": "font/woff2",
143
+ ".ttf": "font/ttf",
144
+ ".otf": "font/otf",
145
+ ".css": "text/css",
146
+ ".js": "text/javascript",
147
+ ".mjs": "text/javascript",
148
+ ".json": "application/json",
149
+ }
150
+
151
+
152
+ @dataclass
153
+ class InlineResult:
154
+ html: str
155
+ bytes: int
156
+ warnings: list[str] = field(default_factory=list)
157
+
158
+
159
+ def _mime_for(p: Path) -> str:
160
+ return _MIME.get(p.suffix.lower(), "application/octet-stream")
161
+
162
+
163
+ def _is_local_ref(ref: str) -> bool:
164
+ return bool(ref) and (
165
+ re.match(r"^(https?:)?//", ref, re.I) is None
166
+ and not ref.startswith(("data:", "#", "mailto:", "blob:"))
167
+ )
168
+
169
+
170
+ def _attr(tag: str, name: str) -> str | None:
171
+ m = re.search(rf"\b{name}\s*=\s*(\"([^\"]*)\"|'([^']*)'|([^\s>]+))", tag, re.I)
172
+ if not m:
173
+ return None
174
+ return m.group(2) or m.group(3) or m.group(4)
175
+
176
+
177
+ def _resolve_local(root: Path, base_dir: Path, ref: str) -> Path | None:
178
+ clean = ref.split("#")[0].split("?")[0]
179
+ if not clean:
180
+ return None
181
+ base = base_dir
182
+ if clean.startswith("/"):
183
+ base = root
184
+ clean = clean.lstrip("/")
185
+ try:
186
+ abs_ = (base / unquote(clean)).resolve()
187
+ except Exception:
188
+ return None
189
+ root_resolved = root.resolve()
190
+ if abs_ == root_resolved:
191
+ return None
192
+ try:
193
+ abs_.relative_to(root_resolved)
194
+ except ValueError:
195
+ return None # escapes the output root — path-traversal guard
196
+ if not abs_.is_file():
197
+ return None
198
+ return abs_
199
+
200
+
201
+ def _data_uri(file: Path) -> str:
202
+ encoded = base64.b64encode(file.read_bytes()).decode("ascii")
203
+ return f"data:{_mime_for(file)};base64,{encoded}"
204
+
205
+
206
+ def _inline_css_urls(root: Path, css_dir: Path, css: str, warnings: list[str]) -> str:
207
+ def repl(m: re.Match[str]) -> str:
208
+ ref = m.group(2)
209
+ if not _is_local_ref(ref):
210
+ return m.group(0)
211
+ file = _resolve_local(root, css_dir, ref)
212
+ if not file:
213
+ warnings.append(f"unresolved css asset: {ref}")
214
+ return m.group(0)
215
+ return f"url({_data_uri(file)})"
216
+
217
+ return re.sub(r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", repl, css, flags=re.I)
218
+
219
+
220
+ def inline_html(root: Path, html_file: Path) -> InlineResult:
221
+ """Inline an entry HTML file and its local assets into one document.
222
+
223
+ Scripts and stylesheets are inlined, referenced images/icons/fonts become
224
+ data URIs, and now-redundant preload links are dropped. External (http/https)
225
+ references are left untouched; unresolvable local references are left in place
226
+ and reported as warnings rather than failing the build.
227
+ """
228
+ if not html_file.exists():
229
+ raise HTMLShipError(f"entry HTML not found: {html_file}")
230
+ html_dir = html_file.parent
231
+ warnings: list[str] = []
232
+ html = html_file.read_text(encoding="utf-8")
233
+
234
+ def local_file(ref: str) -> Path | None:
235
+ if not _is_local_ref(ref):
236
+ return None
237
+ f = _resolve_local(root, html_dir, ref)
238
+ if not f:
239
+ warnings.append(f"unresolved asset: {ref}")
240
+ return None
241
+ return f
242
+
243
+ def link_repl(m: re.Match[str]) -> str:
244
+ tag = m.group(0)
245
+ rel = (_attr(tag, "rel") or "").lower()
246
+ href = _attr(tag, "href")
247
+ if not href:
248
+ return tag
249
+ if rel == "stylesheet":
250
+ f = local_file(href)
251
+ if not f:
252
+ return tag
253
+ css = _inline_css_urls(root, f.parent, f.read_text(encoding="utf-8"), warnings)
254
+ return f"<style>{css}</style>"
255
+ if rel in ("modulepreload", "preload", "prefetch"):
256
+ return "" if _is_local_ref(href) else tag
257
+ if rel in ("icon", "shortcut icon", "apple-touch-icon"):
258
+ f = local_file(href)
259
+ return tag.replace(href, _data_uri(f)) if f else tag
260
+ return tag
261
+
262
+ html = re.sub(r"<link\b[^>]*>", link_repl, html, flags=re.I)
263
+
264
+ def script_repl(m: re.Match[str]) -> str:
265
+ tag = m.group(0)
266
+ attrs = m.group(1)
267
+ src = _attr(tag, "src")
268
+ if not src:
269
+ return tag
270
+ f = local_file(src)
271
+ if not f:
272
+ return tag
273
+ js = re.sub(r"</script", lambda _m: "<\\/script", f.read_text(encoding="utf-8"), flags=re.I)
274
+ type_attr = ' type="module"' if re.search(r'\btype\s*=\s*["\']module["\']', attrs, re.I) else ""
275
+ return f"<script{type_attr}>{js}</script>"
276
+
277
+ html = re.sub(r"<script\b([^>]*)>\s*</script>", script_repl, html, flags=re.I)
278
+
279
+ def img_repl(m: re.Match[str]) -> str:
280
+ tag = m.group(0)
281
+ src = _attr(tag, "src")
282
+ if not src or not _is_local_ref(src):
283
+ return tag
284
+ f = local_file(src)
285
+ return tag.replace(src, _data_uri(f)) if f else tag
286
+
287
+ html = re.sub(r"<img\b[^>]*>", img_repl, html, flags=re.I)
288
+
289
+ return InlineResult(html=html, bytes=len(html.encode("utf-8")), warnings=warnings)
290
+
291
+
292
+ def format_bytes(n: int) -> str:
293
+ if n < 1024:
294
+ return f"{n} B"
295
+ if n < 1024 * 1024:
296
+ return f"{n / 1024:.1f} KB"
297
+ return f"{n / (1024 * 1024):.2f} MB"
298
+
299
+
300
+ def build_and_inline(
301
+ directory: Path,
302
+ *,
303
+ build_cmd: str | None = None,
304
+ out: str | None = None,
305
+ entry: str | None = None,
306
+ install: bool = False,
307
+ timeout: int = DEFAULT_BUILD_TIMEOUT,
308
+ log: LogFn = _noop,
309
+ ) -> InlineResult:
310
+ """Detect, build locally, and inline a project into one self-contained document."""
311
+ project = detect_project(directory, build_cmd)
312
+ log(f"project: {directory}")
313
+ log(f"pkg mgr: {project.package_manager}")
314
+ log(f"build: {build_cmd or build_command(project.package_manager, project.build_script)}")
315
+
316
+ run_build(project, install=install, build_cmd=build_cmd, timeout=timeout, log=log)
317
+
318
+ out_dir = resolve_output_dir(directory, out)
319
+ entry_file = out_dir / (entry or "index.html")
320
+ result = inline_html(out_dir, entry_file)
321
+ log(f"output: {out_dir}")
322
+ log(f"inlined: {format_bytes(result.bytes)} single HTML")
323
+ for w in result.warnings:
324
+ log(f"warning: {w}")
325
+ return result
@@ -60,6 +60,14 @@ def _lookup_owner_key(slug: str) -> str | None:
60
60
  return _load_keys().get(slug, {}).get("owner_key")
61
61
 
62
62
 
63
+ def _prompt_owner_key(slug: str) -> str:
64
+ """Ask for the owner key when it isn't saved locally or passed via --owner-key."""
65
+ key = click.prompt(f"owner_key for '{slug}'").strip()
66
+ if not key:
67
+ raise click.ClickException("owner_key is required")
68
+ return key
69
+
70
+
63
71
  # --- helpers ------------------------------------------------------------------
64
72
 
65
73
 
@@ -168,6 +176,107 @@ def publish(
168
176
  click.echo("(URL copied to clipboard)", err=True)
169
177
 
170
178
 
179
+ @main.command()
180
+ @click.argument("dir", required=False, default=".")
181
+ @click.option("--build-cmd", default=None, help="Override the build command (default: detected from package.json).")
182
+ @click.option("--out", default=None, help="Build output dir (default: auto-detect dist/ or build/).")
183
+ @click.option("--entry", default=None, help="Entry HTML within the output dir (default: index.html).")
184
+ @click.option("--install", is_flag=True, help="Run dependency install before building.")
185
+ @click.option("--dry-run", is_flag=True, help="Build and inline, but report a summary instead of publishing.")
186
+ @click.option("--title", default=None, help="Optional title.")
187
+ @click.option("--password", default=None, help="Password-protect the page.")
188
+ @click.option(
189
+ "--expires-in",
190
+ type=int,
191
+ default=None,
192
+ help="Minutes until expiry (1–10080, i.e. up to 7 days).",
193
+ )
194
+ @click.option("--no-clipboard", is_flag=True, help="Don't copy URL to clipboard.")
195
+ @click.option("--quiet", "-q", is_flag=True, help="Print only the URL.")
196
+ @click.pass_context
197
+ def deploy(
198
+ ctx: click.Context,
199
+ dir: str,
200
+ build_cmd: str | None,
201
+ out: str | None,
202
+ entry: str | None,
203
+ install: bool,
204
+ dry_run: bool,
205
+ title: str | None,
206
+ password: str | None,
207
+ expires_in: int | None,
208
+ no_clipboard: bool,
209
+ quiet: bool,
210
+ ) -> None:
211
+ """Build a frontend project and publish the compiled app as one self-contained page.
212
+
213
+ Runs the project's build script locally, inlines the output into a single
214
+ HTML file, and publishes it with relaxed sandboxing so its JavaScript runs
215
+ in an isolated origin.
216
+
217
+ Examples:
218
+
219
+ htmlship deploy ./my-app
220
+ htmlship deploy --build-cmd "vite build" --out dist
221
+ """
222
+ from pathlib import Path
223
+
224
+ from .build import MAX_PAYLOAD_BYTES, build_and_inline, format_bytes
225
+
226
+ project_dir = Path(dir).resolve()
227
+ log = (lambda _m: None) if quiet else (lambda m: click.echo(m, err=True))
228
+
229
+ try:
230
+ result = build_and_inline(
231
+ project_dir,
232
+ build_cmd=build_cmd,
233
+ out=out,
234
+ entry=entry,
235
+ install=install,
236
+ log=log,
237
+ )
238
+ except HTMLShipError as exc:
239
+ raise click.ClickException(str(exc)) from exc
240
+
241
+ if result.bytes > MAX_PAYLOAD_BYTES:
242
+ raise click.ClickException(
243
+ f"inlined page is {format_bytes(result.bytes)}, exceeds the 10 MB limit"
244
+ )
245
+
246
+ if dry_run:
247
+ log("dry-run: built and inlined OK; not published")
248
+ return
249
+
250
+ client = _make_client(ctx.obj["api_url"])
251
+ try:
252
+ page = client.publish(
253
+ result.html,
254
+ title=title,
255
+ password=password,
256
+ expires_in=expires_in,
257
+ sandbox_mode="relaxed",
258
+ )
259
+ except HTMLShipError as exc:
260
+ raise click.ClickException(str(exc)) from exc
261
+ finally:
262
+ client.close()
263
+
264
+ _remember(page.slug, page.owner_key or "", page.url, title)
265
+
266
+ if quiet:
267
+ click.echo(page.url)
268
+ return
269
+
270
+ click.echo(page.url)
271
+ click.echo(f"slug: {page.slug}", err=True)
272
+ click.echo(f"owner_key: {page.owner_key} (saved to {KEYS_FILE})", err=True)
273
+ click.echo("sandbox: relaxed (scripts run in an isolated origin)", err=True)
274
+ if page.expires_at:
275
+ click.echo(f"expires: {page.expires_at.isoformat()}", err=True)
276
+ if not no_clipboard and _try_clipboard(page.url):
277
+ click.echo("(URL copied to clipboard)", err=True)
278
+
279
+
171
280
  @main.command()
172
281
  @click.argument("slug")
173
282
  @click.pass_context
@@ -205,7 +314,7 @@ def get(ctx: click.Context, slug: str) -> None:
205
314
  @click.argument("source", required=False)
206
315
  @click.option("--file", "-f", "file_", type=click.Path(exists=True, dir_okay=False))
207
316
  @click.option("--title", default=None, help="Optional new title.")
208
- @click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
317
+ @click.option("--owner-key", default=None, help="Owner key (defaults to local store, else prompted).")
209
318
  @click.pass_context
210
319
  def update(
211
320
  ctx: click.Context,
@@ -217,11 +326,7 @@ def update(
217
326
  ) -> None:
218
327
  """Replace HTML for an existing page."""
219
328
  html = _read_html(source, file_)
220
- key = owner_key or _lookup_owner_key(slug)
221
- if not key:
222
- raise click.ClickException(
223
- f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
224
- )
329
+ key = owner_key or _lookup_owner_key(slug) or _prompt_owner_key(slug)
225
330
  client = _make_client(ctx.obj["api_url"])
226
331
  try:
227
332
  page = client.update(slug, html, owner_key=key, title=title)
@@ -235,18 +340,16 @@ def update(
235
340
 
236
341
  @main.command()
237
342
  @click.argument("slug")
238
- @click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
343
+ @click.option("--owner-key", default=None, help="Owner key (defaults to local store, else prompted).")
239
344
  @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
240
345
  @click.pass_context
241
346
  def delete(ctx: click.Context, slug: str, owner_key: str | None, yes: bool) -> None:
242
347
  """Soft-delete a page."""
243
348
  key = owner_key or _lookup_owner_key(slug)
244
- if not key:
245
- raise click.ClickException(
246
- f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
247
- )
248
349
  if not yes:
249
350
  click.confirm(f"Delete page '{slug}'?", abort=True)
351
+ if not key:
352
+ key = _prompt_owner_key(slug)
250
353
  client = _make_client(ctx.obj["api_url"])
251
354
  try:
252
355
  client.delete(slug, owner_key=key)
@@ -90,6 +90,48 @@ class HTMLShipClient:
90
90
  _client=self,
91
91
  )
92
92
 
93
+ def deploy(
94
+ self,
95
+ project_dir: str | Any,
96
+ *,
97
+ build_cmd: str | None = None,
98
+ out: str | None = None,
99
+ entry: str | None = None,
100
+ install: bool = False,
101
+ title: str | None = None,
102
+ password: str | None = None,
103
+ expires_in: int | None = None,
104
+ ) -> Page:
105
+ """Build a local frontend project and publish it as one self-contained page.
106
+
107
+ Detects the project, runs its build *locally* (never on the server),
108
+ inlines the output into a single HTML document, and publishes it as a
109
+ relaxed (sandboxed-script) page so its JavaScript runs in an isolated
110
+ origin. Returns a Page with owner_key set.
111
+ """
112
+ from pathlib import Path
113
+
114
+ from .build import MAX_PAYLOAD_BYTES, build_and_inline
115
+
116
+ result = build_and_inline(
117
+ Path(project_dir),
118
+ build_cmd=build_cmd,
119
+ out=out,
120
+ entry=entry,
121
+ install=install,
122
+ )
123
+ if result.bytes > MAX_PAYLOAD_BYTES:
124
+ raise HTMLShipError(
125
+ f"inlined page is {result.bytes} bytes, exceeds the 10 MB limit"
126
+ )
127
+ return self.publish(
128
+ result.html,
129
+ title=title,
130
+ password=password,
131
+ expires_in=expires_in,
132
+ sandbox_mode="relaxed",
133
+ )
134
+
93
135
  def get(self, slug: str) -> Page:
94
136
  """Fetch metadata for a page. owner_key is None on the returned object."""
95
137
  r = self._http.get(f"/api/v1/pages/{slug}")
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1,8 +1,9 @@
1
1
  """HTMLShip MCP server.
2
2
 
3
- Exposes three tools — publish_html, fetch_html, update_html — that wrap the
4
- public Python library. All HTTP traffic goes through the library and the
5
- real API; this server adds no business logic of its own.
3
+ Exposes four tools — publish_html, deploy_project, fetch_html, update_html —
4
+ that wrap the public Python library. All HTTP traffic goes through the library
5
+ and the real API; this server adds no business logic of its own. ``deploy_project``
6
+ additionally runs a project's build script locally before publishing.
6
7
 
7
8
  Configure the API endpoint via the HTMLSHIP_API_URL environment variable.
8
9
  """
@@ -65,6 +66,58 @@ def publish_html(
65
66
  }
66
67
 
67
68
 
69
+ @mcp.tool()
70
+ def deploy_project(
71
+ dir: str,
72
+ build_cmd: str | None = None,
73
+ out: str | None = None,
74
+ install: bool = False,
75
+ title: str | None = None,
76
+ password: str | None = None,
77
+ expires_in: int | None = None,
78
+ ) -> dict[str, Any]:
79
+ """Build a local frontend project and publish the compiled app as one page.
80
+
81
+ Runs the project's build script ON THIS MACHINE (npm/pnpm/yarn/bun,
82
+ auto-detected), inlines the output (dist/ or build/) into a single HTML
83
+ file, and publishes it with relaxed sandboxing so its JavaScript runs in an
84
+ isolated origin (no cookies, no same-origin fetch, no network egress).
85
+
86
+ Args:
87
+ dir: Path to the project directory (must contain package.json).
88
+ build_cmd: Override the build command (default: detected build/build:prod script).
89
+ out: Build output directory (default: auto-detect dist/ or build/).
90
+ install: Run dependency install before building.
91
+ title: Optional human-readable title.
92
+ password: Optional password required before viewing the page.
93
+ expires_in: Optional TTL in minutes (1–10080, i.e. up to 7 days).
94
+
95
+ Returns:
96
+ A dict with keys: url, slug, owner_key, expires_at.
97
+ IMPORTANT: owner_key is the only credential to update or delete the
98
+ page later. Save it.
99
+ """
100
+ with _client() as c:
101
+ try:
102
+ page = c.deploy(
103
+ dir,
104
+ build_cmd=build_cmd,
105
+ out=out,
106
+ install=install,
107
+ title=title,
108
+ password=password,
109
+ expires_in=expires_in,
110
+ )
111
+ except HTMLShipError as exc:
112
+ raise RuntimeError(f"htmlship deploy failed: {exc}") from exc
113
+ return {
114
+ "url": page.url,
115
+ "slug": page.slug,
116
+ "owner_key": page.owner_key,
117
+ "expires_at": page.expires_at.isoformat() if page.expires_at else None,
118
+ }
119
+
120
+
68
121
  @mcp.tool()
69
122
  def fetch_html(slug: str) -> dict[str, Any]:
70
123
  """Fetch a page's metadata.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from contextlib import asynccontextmanager
4
4
  from pathlib import Path
5
5
 
6
- from fastapi import FastAPI
6
+ from fastapi import FastAPI, Request
7
7
  from fastapi.responses import FileResponse, HTMLResponse, Response
8
8
  from starlette.staticfiles import StaticFiles
9
9
 
@@ -53,11 +53,30 @@ def create_app() -> FastAPI:
53
53
  async def landing_js() -> Response:
54
54
  return FileResponse(web_dir / "demo.js", media_type="application/javascript")
55
55
 
56
+ @app.get("/favicon.ico", include_in_schema=False)
57
+ async def landing_favicon() -> Response:
58
+ return FileResponse(web_dir / "assets" / "favicon.ico", media_type="image/x-icon")
59
+
56
60
  @app.get("/llms.txt", include_in_schema=False)
57
61
  @app.get("/llm.txt", include_in_schema=False)
58
62
  async def landing_llms() -> Response:
59
63
  return FileResponse(web_dir / "llms.txt", media_type="text/plain")
60
64
 
65
+ @app.get("/robots.txt", include_in_schema=False)
66
+ async def robots(request: Request) -> Response:
67
+ # User-generated pages live on the view subdomain and shouldn't be
68
+ # indexed (they expire and can be deleted); serve a disallow-all there
69
+ # and the welcoming policy on the landing/api hosts.
70
+ if request.scope.get("htmlship_host_kind") == "view":
71
+ return Response("User-agent: *\nDisallow: /\n", media_type="text/plain")
72
+ return FileResponse(web_dir / "robots.txt", media_type="text/plain")
73
+
74
+ @app.get("/sitemap.xml", include_in_schema=False)
75
+ async def sitemap() -> Response:
76
+ # Public marketing site only; the view host 404s this (handled by the
77
+ # host-routing middleware), keeping ephemeral pages out of sitemaps.
78
+ return FileResponse(web_dir / "sitemap.xml", media_type="application/xml")
79
+
61
80
  # The view router last so that /{slug} doesn't shadow the landing routes above.
62
81
  app.include_router(view.router)
63
82
 
@@ -11,7 +11,10 @@ API_HOST_PREFIX = "api."
11
11
  SLUG_CHARS = frozenset("0123456789abcdefghijklmnopqrstuvwxyz")
12
12
 
13
13
  # Paths always allowed on every host kind.
14
- ALWAYS_ALLOWED_PATHS = frozenset({"/health", "/version", "/api/openapi.json", "/api/docs"})
14
+ # /robots.txt is served on every host (the view host returns a disallow-all body).
15
+ ALWAYS_ALLOWED_PATHS = frozenset(
16
+ {"/health", "/version", "/api/openapi.json", "/api/docs", "/robots.txt"}
17
+ )
15
18
 
16
19
  # Path prefixes allowed on every host kind (e.g., FastAPI internals).
17
20
  ALWAYS_ALLOWED_PREFIXES = ("/docs/", "/api/openapi", "/api/docs")
@@ -25,15 +25,47 @@ STRICT_CSP = (
25
25
  "frame-ancestors *"
26
26
  )
27
27
 
28
- SECURITY_HEADERS = {
29
- "Content-Security-Policy": STRICT_CSP,
30
- "X-Content-Type-Options": "nosniff",
31
- "Referrer-Policy": "no-referrer",
32
- "Permissions-Policy": (
33
- "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=()"
34
- ),
35
- "Cache-Control": "public, max-age=3600",
36
- }
28
+ # Relaxed mode: for self-contained built apps (e.g. a compiled React bundle
29
+ # inlined into one HTML file) the page's own inline scripts must run.
30
+ #
31
+ # `sandbox allow-scripts` (deliberately WITHOUT allow-same-origin) forces the
32
+ # document into an opaque origin: scripts run, but they cannot read cookies or
33
+ # localStorage, and cannot make same-origin requests to other slugs — which is
34
+ # what otherwise defeats password protection on neighbouring pages. Achieving
35
+ # this with a header (rather than an iframe wrapper or a separate raw endpoint)
36
+ # keeps the body served verbatim and avoids a directly-reachable bypass URL.
37
+ #
38
+ # `connect-src 'none'` blocks network exfiltration as defense-in-depth; only
39
+ # the page's own inline script/style and https/data assets are permitted.
40
+ RELAXED_CSP = (
41
+ "sandbox allow-scripts; "
42
+ "default-src 'none'; "
43
+ "script-src 'unsafe-inline'; "
44
+ "style-src 'unsafe-inline' https:; "
45
+ "img-src data: https:; "
46
+ "font-src https: data:; "
47
+ "media-src https:; "
48
+ "connect-src 'none'; "
49
+ "form-action 'none'; "
50
+ "base-uri 'none'; "
51
+ "frame-ancestors *"
52
+ )
53
+
54
+
55
+ def _security_headers(csp: str) -> dict[str, str]:
56
+ return {
57
+ "Content-Security-Policy": csp,
58
+ "X-Content-Type-Options": "nosniff",
59
+ "Referrer-Policy": "no-referrer",
60
+ "Permissions-Policy": (
61
+ "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=()"
62
+ ),
63
+ "Cache-Control": "public, max-age=3600",
64
+ }
65
+
66
+
67
+ SECURITY_HEADERS = _security_headers(STRICT_CSP)
68
+ RELAXED_SECURITY_HEADERS = _security_headers(RELAXED_CSP)
37
69
 
38
70
 
39
71
  PASSWORD_FORM_TEMPLATE = """<!doctype html>
@@ -123,7 +155,12 @@ async def _load_active_view(session: AsyncSession, slug: str) -> Page:
123
155
  async def _serve_html(page: Page) -> HTMLResponse:
124
156
  store = get_blob_store()
125
157
  data = await store.get(page.blob_key)
126
- return HTMLResponse(data, headers=SECURITY_HEADERS)
158
+ headers = (
159
+ RELAXED_SECURITY_HEADERS
160
+ if page.sandbox_mode == "relaxed"
161
+ else SECURITY_HEADERS
162
+ )
163
+ return HTMLResponse(data, headers=headers)
127
164
 
128
165
 
129
166
  async def _bump_view_count(session: AsyncSession, page_id) -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
File without changes