htmlship 0.1.2__tar.gz → 0.1.3__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.
- {htmlship-0.1.2 → htmlship-0.1.3}/PKG-INFO +21 -21
- {htmlship-0.1.2 → htmlship-0.1.3}/README.md +20 -20
- {htmlship-0.1.2 → htmlship-0.1.3}/pyproject.toml +1 -1
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship/__init__.py +10 -10
- htmlship-0.1.3/src/htmlship/_version.py +1 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship/cli.py +35 -35
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship/client.py +17 -17
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship/exceptions.py +1 -1
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship/models.py +10 -10
- htmlship-0.1.3/src/htmlship_mcp/__init__.py +1 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_mcp/server.py +28 -28
- htmlship-0.1.3/src/htmlship_server/__init__.py +1 -0
- htmlship-0.1.3/src/htmlship_server/db_models/__init__.py +3 -0
- htmlship-0.1.2/src/htmlship_server/db_models/paste.py → htmlship-0.1.3/src/htmlship_server/db_models/page.py +6 -6
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/exceptions.py +1 -1
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/main.py +2 -2
- htmlship-0.1.2/src/htmlship_server/routers/pastes.py → htmlship-0.1.3/src/htmlship_server/routers/pages.py +76 -76
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/routers/view.py +24 -24
- htmlship-0.1.2/src/htmlship_server/schemas/pastes.py → htmlship-0.1.3/src/htmlship_server/schemas/pages.py +5 -5
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/storage.py +2 -2
- htmlship-0.1.2/src/htmlship/_version.py +0 -1
- htmlship-0.1.2/src/htmlship_mcp/__init__.py +0 -1
- htmlship-0.1.2/src/htmlship_server/__init__.py +0 -1
- htmlship-0.1.2/src/htmlship_server/db_models/__init__.py +0 -3
- {htmlship-0.1.2 → htmlship-0.1.3}/.gitignore +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/config.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/database.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/middleware.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/routers/__init__.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/routers/meta.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/schemas/__init__.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/security.py +0 -0
- {htmlship-0.1.2 → htmlship-0.1.3}/src/htmlship_server/slugs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlship
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -50,7 +50,7 @@ HTMLShip has four surfaces:
|
|
|
50
50
|
|
|
51
51
|
- a public Python library and CLI (`htmlship` on PyPI)
|
|
52
52
|
- a Node.js CLI and MCP server (`htmlship` on npm — no install required, runs via `npx`)
|
|
53
|
-
- a FastAPI service for creating, updating, deleting, and viewing HTML
|
|
53
|
+
- a FastAPI service for creating, updating, deleting, and viewing HTML pages
|
|
54
54
|
- a stdio MCP server (the `mcp` subcommand of both CLIs) for agent clients
|
|
55
55
|
|
|
56
56
|
```bash
|
|
@@ -64,13 +64,13 @@ pip install htmlship
|
|
|
64
64
|
```python
|
|
65
65
|
import htmlship
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
print(
|
|
69
|
-
print(
|
|
67
|
+
page = htmlship.publish("<h1>Hello</h1>", title="Demo", expires_in=60) # minutes
|
|
68
|
+
print(page.url)
|
|
69
|
+
print(page.owner_key) # save this to update or delete the page later
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
curl -X POST https://api.htmlship.com/api/v1/
|
|
73
|
+
curl -X POST https://api.htmlship.com/api/v1/pages \
|
|
74
74
|
-H "Content-Type: application/json" \
|
|
75
75
|
-d '{"html":"<h1>Hello</h1>","title":"Demo"}'
|
|
76
76
|
```
|
|
@@ -84,23 +84,23 @@ The module-level helpers use `https://api.htmlship.com` by default. Override wit
|
|
|
84
84
|
```python
|
|
85
85
|
import htmlship
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
page = htmlship.publish(
|
|
88
88
|
"<h1>Hello</h1>",
|
|
89
89
|
title="Demo",
|
|
90
90
|
password="optional-password",
|
|
91
91
|
expires_in=1440, # minutes (24 hours)
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
-
fresh = htmlship.get(
|
|
95
|
-
updated = htmlship.update(
|
|
96
|
-
htmlship.delete(updated.slug, owner_key=
|
|
94
|
+
fresh = htmlship.get(page.slug)
|
|
95
|
+
updated = htmlship.update(page.slug, "<h1>Updated</h1>", owner_key=page.owner_key)
|
|
96
|
+
htmlship.delete(updated.slug, owner_key=page.owner_key)
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
`owner_key` is returned only when a
|
|
99
|
+
`owner_key` is returned only when a page is created. It is required for updates and deletes, and the API does not return it again from metadata calls.
|
|
100
100
|
|
|
101
101
|
## CLI
|
|
102
102
|
|
|
103
|
-
The CLI is shipped both as a Python package (`pip install htmlship`) and an npm package (`npx htmlship` or `npm i -g htmlship`). The two share the same on-disk owner-key store, so a
|
|
103
|
+
The CLI is shipped both as a Python package (`pip install htmlship`) and an npm package (`npx htmlship` or `npm i -g htmlship`). The two share the same on-disk owner-key store, so a page created in one is editable from the other.
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
htmlship publish report.html
|
|
@@ -120,7 +120,7 @@ npx htmlship publish report.html
|
|
|
120
120
|
npx htmlship list-mine
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with
|
|
123
|
+
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.
|
|
124
124
|
|
|
125
125
|
## API
|
|
126
126
|
|
|
@@ -130,11 +130,11 @@ Base URL: `https://api.htmlship.com`.
|
|
|
130
130
|
| --- | --- | --- |
|
|
131
131
|
| `GET` | `/health` | Health check with service version. |
|
|
132
132
|
| `GET` | `/version` | Service version. |
|
|
133
|
-
| `POST` | `/api/v1/
|
|
134
|
-
| `GET` | `/api/v1/
|
|
135
|
-
| `PATCH` | `/api/v1/
|
|
136
|
-
| `DELETE` | `/api/v1/
|
|
137
|
-
| `POST` | `/api/v1/
|
|
133
|
+
| `POST` | `/api/v1/pages` | Create a page. |
|
|
134
|
+
| `GET` | `/api/v1/pages/{slug}` | Fetch page metadata. |
|
|
135
|
+
| `PATCH` | `/api/v1/pages/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
|
|
136
|
+
| `DELETE` | `/api/v1/pages/{slug}` | Soft-delete a page. Requires `X-Owner-Key`. |
|
|
137
|
+
| `POST` | `/api/v1/pages/{slug}/version` | Create a new page linked to an existing parent slug. |
|
|
138
138
|
|
|
139
139
|
Create payload:
|
|
140
140
|
|
|
@@ -278,7 +278,7 @@ The server reads `.env` via Pydantic settings.
|
|
|
278
278
|
| `DATABASE_URL` | `postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship` | Async SQLAlchemy database URL. |
|
|
279
279
|
| `PUBLIC_BASE_DOMAIN` | `htmlship.com` | Base domain used to derive host routing. |
|
|
280
280
|
| `API_BASE_URL` | `https://api.htmlship.com` | Public API URL setting. |
|
|
281
|
-
| `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in
|
|
281
|
+
| `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in page responses. |
|
|
282
282
|
| `LANDING_BASE_URL` | `https://htmlship.com` | Public landing URL. |
|
|
283
283
|
| `SPACES_BUCKET` | empty | If empty, use local blob storage; otherwise use DigitalOcean Spaces/S3. |
|
|
284
284
|
| `SPACES_REGION` | `nyc3` | Spaces/S3 region. |
|
|
@@ -288,13 +288,13 @@ The server reads `.env` via Pydantic settings.
|
|
|
288
288
|
| `ENVIRONMENT` | `development` | Enables API docs outside production and secure cookies in production. |
|
|
289
289
|
| `LOG_LEVEL` | `info` | Application log level. |
|
|
290
290
|
| `MAX_PAYLOAD_BYTES` | `10485760` | Server-side HTML size limit. |
|
|
291
|
-
| `DEFAULT_EXPIRES_IN_MINUTES` | empty | Optional default TTL (minutes) for new
|
|
291
|
+
| `DEFAULT_EXPIRES_IN_MINUTES` | empty | Optional default TTL (minutes) for new pages. |
|
|
292
292
|
|
|
293
293
|
## Architecture
|
|
294
294
|
|
|
295
295
|
One FastAPI process hosts the landing page, JSON API, and view renderer. `HostRoutingMiddleware` classifies requests by host and prevents API routes from being served on the view host.
|
|
296
296
|
|
|
297
|
-
Postgres stores
|
|
297
|
+
Postgres stores page metadata, owner-key/password hashes, expiry, view counts, and parent-version links. HTML bodies are stored as blobs, either in `LocalBlobStore` for development/tests or DigitalOcean Spaces in production.
|
|
298
298
|
|
|
299
299
|
## Project Layout
|
|
300
300
|
|
|
@@ -6,7 +6,7 @@ HTMLShip has four surfaces:
|
|
|
6
6
|
|
|
7
7
|
- a public Python library and CLI (`htmlship` on PyPI)
|
|
8
8
|
- a Node.js CLI and MCP server (`htmlship` on npm — no install required, runs via `npx`)
|
|
9
|
-
- a FastAPI service for creating, updating, deleting, and viewing HTML
|
|
9
|
+
- a FastAPI service for creating, updating, deleting, and viewing HTML pages
|
|
10
10
|
- a stdio MCP server (the `mcp` subcommand of both CLIs) for agent clients
|
|
11
11
|
|
|
12
12
|
```bash
|
|
@@ -20,13 +20,13 @@ pip install htmlship
|
|
|
20
20
|
```python
|
|
21
21
|
import htmlship
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
print(
|
|
25
|
-
print(
|
|
23
|
+
page = htmlship.publish("<h1>Hello</h1>", title="Demo", expires_in=60) # minutes
|
|
24
|
+
print(page.url)
|
|
25
|
+
print(page.owner_key) # save this to update or delete the page later
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
curl -X POST https://api.htmlship.com/api/v1/
|
|
29
|
+
curl -X POST https://api.htmlship.com/api/v1/pages \
|
|
30
30
|
-H "Content-Type: application/json" \
|
|
31
31
|
-d '{"html":"<h1>Hello</h1>","title":"Demo"}'
|
|
32
32
|
```
|
|
@@ -40,23 +40,23 @@ The module-level helpers use `https://api.htmlship.com` by default. Override wit
|
|
|
40
40
|
```python
|
|
41
41
|
import htmlship
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
page = htmlship.publish(
|
|
44
44
|
"<h1>Hello</h1>",
|
|
45
45
|
title="Demo",
|
|
46
46
|
password="optional-password",
|
|
47
47
|
expires_in=1440, # minutes (24 hours)
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
-
fresh = htmlship.get(
|
|
51
|
-
updated = htmlship.update(
|
|
52
|
-
htmlship.delete(updated.slug, owner_key=
|
|
50
|
+
fresh = htmlship.get(page.slug)
|
|
51
|
+
updated = htmlship.update(page.slug, "<h1>Updated</h1>", owner_key=page.owner_key)
|
|
52
|
+
htmlship.delete(updated.slug, owner_key=page.owner_key)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
`owner_key` is returned only when a
|
|
55
|
+
`owner_key` is returned only when a page is created. It is required for updates and deletes, and the API does not return it again from metadata calls.
|
|
56
56
|
|
|
57
57
|
## CLI
|
|
58
58
|
|
|
59
|
-
The CLI is shipped both as a Python package (`pip install htmlship`) and an npm package (`npx htmlship` or `npm i -g htmlship`). The two share the same on-disk owner-key store, so a
|
|
59
|
+
The CLI is shipped both as a Python package (`pip install htmlship`) and an npm package (`npx htmlship` or `npm i -g htmlship`). The two share the same on-disk owner-key store, so a page created in one is editable from the other.
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
htmlship publish report.html
|
|
@@ -76,7 +76,7 @@ npx htmlship publish report.html
|
|
|
76
76
|
npx htmlship list-mine
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with
|
|
79
|
+
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.
|
|
80
80
|
|
|
81
81
|
## API
|
|
82
82
|
|
|
@@ -86,11 +86,11 @@ Base URL: `https://api.htmlship.com`.
|
|
|
86
86
|
| --- | --- | --- |
|
|
87
87
|
| `GET` | `/health` | Health check with service version. |
|
|
88
88
|
| `GET` | `/version` | Service version. |
|
|
89
|
-
| `POST` | `/api/v1/
|
|
90
|
-
| `GET` | `/api/v1/
|
|
91
|
-
| `PATCH` | `/api/v1/
|
|
92
|
-
| `DELETE` | `/api/v1/
|
|
93
|
-
| `POST` | `/api/v1/
|
|
89
|
+
| `POST` | `/api/v1/pages` | Create a page. |
|
|
90
|
+
| `GET` | `/api/v1/pages/{slug}` | Fetch page metadata. |
|
|
91
|
+
| `PATCH` | `/api/v1/pages/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
|
|
92
|
+
| `DELETE` | `/api/v1/pages/{slug}` | Soft-delete a page. Requires `X-Owner-Key`. |
|
|
93
|
+
| `POST` | `/api/v1/pages/{slug}/version` | Create a new page linked to an existing parent slug. |
|
|
94
94
|
|
|
95
95
|
Create payload:
|
|
96
96
|
|
|
@@ -234,7 +234,7 @@ The server reads `.env` via Pydantic settings.
|
|
|
234
234
|
| `DATABASE_URL` | `postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship` | Async SQLAlchemy database URL. |
|
|
235
235
|
| `PUBLIC_BASE_DOMAIN` | `htmlship.com` | Base domain used to derive host routing. |
|
|
236
236
|
| `API_BASE_URL` | `https://api.htmlship.com` | Public API URL setting. |
|
|
237
|
-
| `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in
|
|
237
|
+
| `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in page responses. |
|
|
238
238
|
| `LANDING_BASE_URL` | `https://htmlship.com` | Public landing URL. |
|
|
239
239
|
| `SPACES_BUCKET` | empty | If empty, use local blob storage; otherwise use DigitalOcean Spaces/S3. |
|
|
240
240
|
| `SPACES_REGION` | `nyc3` | Spaces/S3 region. |
|
|
@@ -244,13 +244,13 @@ The server reads `.env` via Pydantic settings.
|
|
|
244
244
|
| `ENVIRONMENT` | `development` | Enables API docs outside production and secure cookies in production. |
|
|
245
245
|
| `LOG_LEVEL` | `info` | Application log level. |
|
|
246
246
|
| `MAX_PAYLOAD_BYTES` | `10485760` | Server-side HTML size limit. |
|
|
247
|
-
| `DEFAULT_EXPIRES_IN_MINUTES` | empty | Optional default TTL (minutes) for new
|
|
247
|
+
| `DEFAULT_EXPIRES_IN_MINUTES` | empty | Optional default TTL (minutes) for new pages. |
|
|
248
248
|
|
|
249
249
|
## Architecture
|
|
250
250
|
|
|
251
251
|
One FastAPI process hosts the landing page, JSON API, and view renderer. `HostRoutingMiddleware` classifies requests by host and prevents API routes from being served on the view host.
|
|
252
252
|
|
|
253
|
-
Postgres stores
|
|
253
|
+
Postgres stores page metadata, owner-key/password hashes, expiry, view counts, and parent-version links. HTML bodies are stored as blobs, either in `LocalBlobStore` for development/tests or DigitalOcean Spaces in production.
|
|
254
254
|
|
|
255
255
|
## Project Layout
|
|
256
256
|
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
Quick start:
|
|
4
4
|
|
|
5
5
|
import htmlship
|
|
6
|
-
|
|
7
|
-
print(
|
|
8
|
-
print(
|
|
6
|
+
page = htmlship.publish("<h1>Hello</h1>", expires_in=60) # minutes
|
|
7
|
+
print(page.url)
|
|
8
|
+
print(page.owner_key)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
page.update("<h1>Hello, again</h1>")
|
|
11
|
+
page.delete()
|
|
12
12
|
|
|
13
13
|
Configuration via environment:
|
|
14
14
|
HTMLSHIP_API_URL (default: https://api.htmlship.com)
|
|
@@ -25,7 +25,7 @@ from .exceptions import (
|
|
|
25
25
|
NotFoundError,
|
|
26
26
|
RateLimitError,
|
|
27
27
|
)
|
|
28
|
-
from .models import
|
|
28
|
+
from .models import Page
|
|
29
29
|
|
|
30
30
|
_default_client: HTMLShipClient | None = None
|
|
31
31
|
|
|
@@ -61,7 +61,7 @@ def publish(
|
|
|
61
61
|
expires_in: int | None = None,
|
|
62
62
|
parent_slug: str | None = None,
|
|
63
63
|
sandbox_mode: str = "strict",
|
|
64
|
-
) ->
|
|
64
|
+
) -> Page:
|
|
65
65
|
"""Publish HTML. ``expires_in`` is in minutes (max 10080 = 7 days)."""
|
|
66
66
|
return _get_default_client().publish(
|
|
67
67
|
html,
|
|
@@ -73,11 +73,11 @@ def publish(
|
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def get(slug: str) ->
|
|
76
|
+
def get(slug: str) -> Page:
|
|
77
77
|
return _get_default_client().get(slug)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def update(slug: str, html: str, *, owner_key: str, title: str | None = None) ->
|
|
80
|
+
def update(slug: str, html: str, *, owner_key: str, title: str | None = None) -> Page:
|
|
81
81
|
return _get_default_client().update(slug, html, owner_key=owner_key, title=title)
|
|
82
82
|
|
|
83
83
|
|
|
@@ -88,7 +88,7 @@ def delete(slug: str, *, owner_key: str) -> None:
|
|
|
88
88
|
__all__ = [
|
|
89
89
|
"__version__",
|
|
90
90
|
"HTMLShipClient",
|
|
91
|
-
"
|
|
91
|
+
"Page",
|
|
92
92
|
"HTMLShipError",
|
|
93
93
|
"NotFoundError",
|
|
94
94
|
"AuthError",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -111,7 +111,7 @@ def main(ctx: click.Context, api_url: str | None) -> None:
|
|
|
111
111
|
@click.argument("source", required=False)
|
|
112
112
|
@click.option("--file", "-f", "file_", type=click.Path(exists=True, dir_okay=False), help="HTML file path.")
|
|
113
113
|
@click.option("--title", default=None, help="Optional title.")
|
|
114
|
-
@click.option("--password", default=None, help="Password-protect the
|
|
114
|
+
@click.option("--password", default=None, help="Password-protect the page.")
|
|
115
115
|
@click.option(
|
|
116
116
|
"--expires-in",
|
|
117
117
|
type=int,
|
|
@@ -142,7 +142,7 @@ def publish(
|
|
|
142
142
|
html = _read_html(source, file_)
|
|
143
143
|
client = _make_client(ctx.obj["api_url"])
|
|
144
144
|
try:
|
|
145
|
-
|
|
145
|
+
page = client.publish(
|
|
146
146
|
html,
|
|
147
147
|
title=title,
|
|
148
148
|
password=password,
|
|
@@ -153,18 +153,18 @@ def publish(
|
|
|
153
153
|
finally:
|
|
154
154
|
client.close()
|
|
155
155
|
|
|
156
|
-
_remember(
|
|
156
|
+
_remember(page.slug, page.owner_key or "", page.url, title)
|
|
157
157
|
|
|
158
158
|
if quiet:
|
|
159
|
-
click.echo(
|
|
159
|
+
click.echo(page.url)
|
|
160
160
|
return
|
|
161
161
|
|
|
162
|
-
click.echo(
|
|
163
|
-
click.echo(f"slug: {
|
|
164
|
-
click.echo(f"owner_key: {
|
|
165
|
-
if
|
|
166
|
-
click.echo(f"expires: {
|
|
167
|
-
if not no_clipboard and _try_clipboard(
|
|
162
|
+
click.echo(page.url)
|
|
163
|
+
click.echo(f"slug: {page.slug}", err=True)
|
|
164
|
+
click.echo(f"owner_key: {page.owner_key} (saved to {KEYS_FILE})", err=True)
|
|
165
|
+
if page.expires_at:
|
|
166
|
+
click.echo(f"expires: {page.expires_at.isoformat()}", err=True)
|
|
167
|
+
if not no_clipboard and _try_clipboard(page.url):
|
|
168
168
|
click.echo("(URL copied to clipboard)", err=True)
|
|
169
169
|
|
|
170
170
|
|
|
@@ -172,12 +172,12 @@ def publish(
|
|
|
172
172
|
@click.argument("slug")
|
|
173
173
|
@click.pass_context
|
|
174
174
|
def get(ctx: click.Context, slug: str) -> None:
|
|
175
|
-
"""Show metadata for a
|
|
175
|
+
"""Show metadata for a page."""
|
|
176
176
|
client = _make_client(ctx.obj["api_url"])
|
|
177
177
|
try:
|
|
178
|
-
|
|
178
|
+
page = client.get(slug)
|
|
179
179
|
except NotFoundError as exc:
|
|
180
|
-
raise click.ClickException(f"
|
|
180
|
+
raise click.ClickException(f"page '{slug}' not found") from exc
|
|
181
181
|
except HTMLShipError as exc:
|
|
182
182
|
raise click.ClickException(str(exc)) from exc
|
|
183
183
|
finally:
|
|
@@ -185,16 +185,16 @@ def get(ctx: click.Context, slug: str) -> None:
|
|
|
185
185
|
|
|
186
186
|
click.echo(json.dumps(
|
|
187
187
|
{
|
|
188
|
-
"slug":
|
|
189
|
-
"url":
|
|
190
|
-
"title":
|
|
191
|
-
"view_count":
|
|
192
|
-
"size_bytes":
|
|
193
|
-
"has_password":
|
|
194
|
-
"parent_slug":
|
|
195
|
-
"expires_at":
|
|
196
|
-
"created_at":
|
|
197
|
-
"updated_at":
|
|
188
|
+
"slug": page.slug,
|
|
189
|
+
"url": page.url,
|
|
190
|
+
"title": page.title,
|
|
191
|
+
"view_count": page.view_count,
|
|
192
|
+
"size_bytes": page.size_bytes,
|
|
193
|
+
"has_password": page.has_password,
|
|
194
|
+
"parent_slug": page.parent_slug,
|
|
195
|
+
"expires_at": page.expires_at.isoformat() if page.expires_at else None,
|
|
196
|
+
"created_at": page.created_at.isoformat() if page.created_at else None,
|
|
197
|
+
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
|
198
198
|
},
|
|
199
199
|
indent=2,
|
|
200
200
|
))
|
|
@@ -215,7 +215,7 @@ def update(
|
|
|
215
215
|
title: str | None,
|
|
216
216
|
owner_key: str | None,
|
|
217
217
|
) -> None:
|
|
218
|
-
"""Replace HTML for an existing
|
|
218
|
+
"""Replace HTML for an existing page."""
|
|
219
219
|
html = _read_html(source, file_)
|
|
220
220
|
key = owner_key or _lookup_owner_key(slug)
|
|
221
221
|
if not key:
|
|
@@ -224,13 +224,13 @@ def update(
|
|
|
224
224
|
)
|
|
225
225
|
client = _make_client(ctx.obj["api_url"])
|
|
226
226
|
try:
|
|
227
|
-
|
|
227
|
+
page = client.update(slug, html, owner_key=key, title=title)
|
|
228
228
|
except HTMLShipError as exc:
|
|
229
229
|
raise click.ClickException(str(exc)) from exc
|
|
230
230
|
finally:
|
|
231
231
|
client.close()
|
|
232
232
|
|
|
233
|
-
click.echo(
|
|
233
|
+
click.echo(page.url)
|
|
234
234
|
|
|
235
235
|
|
|
236
236
|
@main.command()
|
|
@@ -239,14 +239,14 @@ def update(
|
|
|
239
239
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
240
240
|
@click.pass_context
|
|
241
241
|
def delete(ctx: click.Context, slug: str, owner_key: str | None, yes: bool) -> None:
|
|
242
|
-
"""Soft-delete a
|
|
242
|
+
"""Soft-delete a page."""
|
|
243
243
|
key = owner_key or _lookup_owner_key(slug)
|
|
244
244
|
if not key:
|
|
245
245
|
raise click.ClickException(
|
|
246
246
|
f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
|
|
247
247
|
)
|
|
248
248
|
if not yes:
|
|
249
|
-
click.confirm(f"Delete
|
|
249
|
+
click.confirm(f"Delete page '{slug}'?", abort=True)
|
|
250
250
|
client = _make_client(ctx.obj["api_url"])
|
|
251
251
|
try:
|
|
252
252
|
client.delete(slug, owner_key=key)
|
|
@@ -262,10 +262,10 @@ def delete(ctx: click.Context, slug: str, owner_key: str | None, yes: bool) -> N
|
|
|
262
262
|
@click.option("--limit", type=int, default=20, show_default=True)
|
|
263
263
|
@click.pass_context
|
|
264
264
|
def list_mine(ctx: click.Context, limit: int) -> None:
|
|
265
|
-
"""List
|
|
265
|
+
"""List pages whose owner keys are saved locally."""
|
|
266
266
|
keys = _load_keys()
|
|
267
267
|
if not keys:
|
|
268
|
-
click.echo(f"No saved
|
|
268
|
+
click.echo(f"No saved pages in {KEYS_FILE}.")
|
|
269
269
|
return
|
|
270
270
|
items = sorted(keys.items(), key=lambda kv: kv[1].get("saved_at", ""), reverse=True)[:limit]
|
|
271
271
|
|
|
@@ -274,13 +274,13 @@ def list_mine(ctx: click.Context, limit: int) -> None:
|
|
|
274
274
|
rows = []
|
|
275
275
|
for slug, info in items:
|
|
276
276
|
try:
|
|
277
|
-
|
|
278
|
-
size = f"{
|
|
279
|
-
views =
|
|
280
|
-
title =
|
|
277
|
+
page = client.get(slug)
|
|
278
|
+
size = f"{page.size_bytes}B"
|
|
279
|
+
views = page.view_count
|
|
280
|
+
title = page.title or info.get("title") or ""
|
|
281
281
|
status = "ok"
|
|
282
282
|
except NotFoundError:
|
|
283
|
-
|
|
283
|
+
page = None
|
|
284
284
|
size = "—"
|
|
285
285
|
views = "—"
|
|
286
286
|
title = info.get("title") or ""
|
|
@@ -8,7 +8,7 @@ import httpx
|
|
|
8
8
|
|
|
9
9
|
from ._version import __version__
|
|
10
10
|
from .exceptions import APIError, AuthError, HTMLShipError, NotFoundError, RateLimitError
|
|
11
|
-
from .models import
|
|
11
|
+
from .models import Page
|
|
12
12
|
|
|
13
13
|
DEFAULT_API_URL = "https://api.htmlship.com"
|
|
14
14
|
DEFAULT_TIMEOUT = 30.0
|
|
@@ -65,10 +65,10 @@ class HTMLShipClient:
|
|
|
65
65
|
expires_in: int | None = None,
|
|
66
66
|
parent_slug: str | None = None,
|
|
67
67
|
sandbox_mode: str = "strict",
|
|
68
|
-
) ->
|
|
69
|
-
"""Create a
|
|
68
|
+
) -> Page:
|
|
69
|
+
"""Create a page. ``expires_in`` is in minutes (max 10080 = 7 days).
|
|
70
70
|
|
|
71
|
-
Returns a
|
|
71
|
+
Returns a Page with owner_key set.
|
|
72
72
|
"""
|
|
73
73
|
body: dict[str, Any] = {"html": html, "sandbox_mode": sandbox_mode}
|
|
74
74
|
if title is not None:
|
|
@@ -79,9 +79,9 @@ class HTMLShipClient:
|
|
|
79
79
|
body["expires_in"] = expires_in
|
|
80
80
|
if parent_slug is not None:
|
|
81
81
|
body["parent_slug"] = parent_slug
|
|
82
|
-
r = self._http.post("/api/v1/
|
|
82
|
+
r = self._http.post("/api/v1/pages", json=body)
|
|
83
83
|
data = self._handle(r)
|
|
84
|
-
return
|
|
84
|
+
return Page(
|
|
85
85
|
slug=data["slug"],
|
|
86
86
|
url=data["url"],
|
|
87
87
|
owner_key=data["owner_key"],
|
|
@@ -90,11 +90,11 @@ class HTMLShipClient:
|
|
|
90
90
|
_client=self,
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
-
def get(self, slug: str) ->
|
|
94
|
-
"""Fetch metadata for a
|
|
95
|
-
r = self._http.get(f"/api/v1/
|
|
93
|
+
def get(self, slug: str) -> Page:
|
|
94
|
+
"""Fetch metadata for a page. owner_key is None on the returned object."""
|
|
95
|
+
r = self._http.get(f"/api/v1/pages/{slug}")
|
|
96
96
|
data = self._handle(r)
|
|
97
|
-
return
|
|
97
|
+
return Page(
|
|
98
98
|
slug=data["slug"],
|
|
99
99
|
url=data["url"],
|
|
100
100
|
owner_key=None,
|
|
@@ -116,17 +116,17 @@ class HTMLShipClient:
|
|
|
116
116
|
*,
|
|
117
117
|
owner_key: str,
|
|
118
118
|
title: str | None = None,
|
|
119
|
-
) ->
|
|
119
|
+
) -> Page:
|
|
120
120
|
body: dict[str, Any] = {"html": html}
|
|
121
121
|
if title is not None:
|
|
122
122
|
body["title"] = title
|
|
123
123
|
r = self._http.patch(
|
|
124
|
-
f"/api/v1/
|
|
124
|
+
f"/api/v1/pages/{slug}",
|
|
125
125
|
json=body,
|
|
126
126
|
headers={"X-Owner-Key": owner_key},
|
|
127
127
|
)
|
|
128
128
|
data = self._handle(r)
|
|
129
|
-
return
|
|
129
|
+
return Page(
|
|
130
130
|
slug=data["slug"],
|
|
131
131
|
url=data["url"],
|
|
132
132
|
owner_key=owner_key,
|
|
@@ -137,7 +137,7 @@ class HTMLShipClient:
|
|
|
137
137
|
|
|
138
138
|
def delete(self, slug: str, *, owner_key: str) -> None:
|
|
139
139
|
r = self._http.delete(
|
|
140
|
-
f"/api/v1/
|
|
140
|
+
f"/api/v1/pages/{slug}",
|
|
141
141
|
headers={"X-Owner-Key": owner_key},
|
|
142
142
|
)
|
|
143
143
|
self._handle(r, expect_no_body=True)
|
|
@@ -150,7 +150,7 @@ class HTMLShipClient:
|
|
|
150
150
|
title: str | None = None,
|
|
151
151
|
password: str | None = None,
|
|
152
152
|
expires_in: int | None = None,
|
|
153
|
-
) ->
|
|
153
|
+
) -> Page:
|
|
154
154
|
body: dict[str, Any] = {"html": html, "sandbox_mode": "strict"}
|
|
155
155
|
if title is not None:
|
|
156
156
|
body["title"] = title
|
|
@@ -158,9 +158,9 @@ class HTMLShipClient:
|
|
|
158
158
|
body["password"] = password
|
|
159
159
|
if expires_in is not None:
|
|
160
160
|
body["expires_in"] = expires_in
|
|
161
|
-
r = self._http.post(f"/api/v1/
|
|
161
|
+
r = self._http.post(f"/api/v1/pages/{parent_slug}/version", json=body)
|
|
162
162
|
data = self._handle(r)
|
|
163
|
-
return
|
|
163
|
+
return Page(
|
|
164
164
|
slug=data["slug"],
|
|
165
165
|
url=data["url"],
|
|
166
166
|
owner_key=data["owner_key"],
|
|
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
|
-
class
|
|
13
|
-
"""A
|
|
12
|
+
class Page:
|
|
13
|
+
"""A page created via the HTMLShip API.
|
|
14
14
|
|
|
15
15
|
The `owner_key` is set on creation and is the only credential to mutate or
|
|
16
|
-
delete the
|
|
16
|
+
delete the page. It is None for pages loaded via `get()` since the server
|
|
17
17
|
does not return it after creation.
|
|
18
18
|
"""
|
|
19
19
|
|
|
@@ -31,10 +31,10 @@ class Paste:
|
|
|
31
31
|
|
|
32
32
|
_client: HTMLShipClient | None = field(default=None, repr=False, compare=False)
|
|
33
33
|
|
|
34
|
-
def update(self, html: str, *, title: str | None = None) ->
|
|
35
|
-
"""Replace this
|
|
34
|
+
def update(self, html: str, *, title: str | None = None) -> Page:
|
|
35
|
+
"""Replace this page's HTML. Requires owner_key."""
|
|
36
36
|
if self._client is None:
|
|
37
|
-
raise RuntimeError("
|
|
37
|
+
raise RuntimeError("Page is detached from a client; reattach to mutate")
|
|
38
38
|
if not self.owner_key:
|
|
39
39
|
raise RuntimeError(
|
|
40
40
|
"owner_key is required to update; load via get() first or pass owner_key explicitly"
|
|
@@ -42,19 +42,19 @@ class Paste:
|
|
|
42
42
|
return self._client.update(self.slug, html, owner_key=self.owner_key, title=title)
|
|
43
43
|
|
|
44
44
|
def delete(self) -> None:
|
|
45
|
-
"""Soft-delete this
|
|
45
|
+
"""Soft-delete this page. Requires owner_key."""
|
|
46
46
|
if self._client is None:
|
|
47
|
-
raise RuntimeError("
|
|
47
|
+
raise RuntimeError("Page is detached from a client; reattach to delete")
|
|
48
48
|
if not self.owner_key:
|
|
49
49
|
raise RuntimeError(
|
|
50
50
|
"owner_key is required to delete; load via get() first or pass owner_key explicitly"
|
|
51
51
|
)
|
|
52
52
|
self._client.delete(self.slug, owner_key=self.owner_key)
|
|
53
53
|
|
|
54
|
-
def fetch_metadata(self) ->
|
|
54
|
+
def fetch_metadata(self) -> Page:
|
|
55
55
|
"""Refresh metadata from the server."""
|
|
56
56
|
if self._client is None:
|
|
57
|
-
raise RuntimeError("
|
|
57
|
+
raise RuntimeError("Page is detached from a client; reattach to fetch metadata")
|
|
58
58
|
fresh = self._client.get(self.slug)
|
|
59
59
|
# Preserve owner_key (server doesn't return it).
|
|
60
60
|
fresh.owner_key = self.owner_key
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -37,81 +37,81 @@ def publish_html(
|
|
|
37
37
|
be blocked by Content Security Policy at view time.
|
|
38
38
|
title: Optional human-readable title.
|
|
39
39
|
expires_in: Optional TTL in minutes (1–10080, i.e. up to 7 days). If
|
|
40
|
-
omitted, the
|
|
40
|
+
omitted, the page is permanent (subject to the server's default
|
|
41
41
|
policy).
|
|
42
42
|
|
|
43
43
|
Returns:
|
|
44
44
|
A dict with keys: url, slug, owner_key, expires_at.
|
|
45
45
|
IMPORTANT: owner_key is the only credential to update or delete the
|
|
46
|
-
|
|
46
|
+
page later. Save it.
|
|
47
47
|
"""
|
|
48
48
|
with _client() as c:
|
|
49
49
|
try:
|
|
50
|
-
|
|
50
|
+
page = c.publish(html, title=title, expires_in=expires_in)
|
|
51
51
|
except HTMLShipError as exc:
|
|
52
52
|
raise RuntimeError(f"htmlship publish failed: {exc}") from exc
|
|
53
53
|
return {
|
|
54
|
-
"url":
|
|
55
|
-
"slug":
|
|
56
|
-
"owner_key":
|
|
57
|
-
"expires_at":
|
|
54
|
+
"url": page.url,
|
|
55
|
+
"slug": page.slug,
|
|
56
|
+
"owner_key": page.owner_key,
|
|
57
|
+
"expires_at": page.expires_at.isoformat() if page.expires_at else None,
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
@mcp.tool()
|
|
62
62
|
def fetch_html(slug: str) -> dict[str, Any]:
|
|
63
|
-
"""Fetch a
|
|
63
|
+
"""Fetch a page's metadata.
|
|
64
64
|
|
|
65
65
|
NOTE: this returns metadata only. To view the rendered HTML, open the
|
|
66
66
|
`url` in a browser — the content is served from a sandboxed subdomain.
|
|
67
67
|
|
|
68
68
|
Args:
|
|
69
|
-
slug: The
|
|
69
|
+
slug: The page's short identifier.
|
|
70
70
|
"""
|
|
71
71
|
with _client() as c:
|
|
72
72
|
try:
|
|
73
|
-
|
|
73
|
+
page = c.get(slug)
|
|
74
74
|
except NotFoundError as exc:
|
|
75
|
-
raise RuntimeError(f"
|
|
75
|
+
raise RuntimeError(f"page '{slug}' not found") from exc
|
|
76
76
|
except HTMLShipError as exc:
|
|
77
77
|
raise RuntimeError(f"htmlship fetch failed: {exc}") from exc
|
|
78
78
|
return {
|
|
79
|
-
"slug":
|
|
80
|
-
"url":
|
|
81
|
-
"title":
|
|
82
|
-
"view_count":
|
|
83
|
-
"size_bytes":
|
|
84
|
-
"has_password":
|
|
85
|
-
"parent_slug":
|
|
86
|
-
"expires_at":
|
|
87
|
-
"created_at":
|
|
88
|
-
"updated_at":
|
|
79
|
+
"slug": page.slug,
|
|
80
|
+
"url": page.url,
|
|
81
|
+
"title": page.title,
|
|
82
|
+
"view_count": page.view_count,
|
|
83
|
+
"size_bytes": page.size_bytes,
|
|
84
|
+
"has_password": page.has_password,
|
|
85
|
+
"parent_slug": page.parent_slug,
|
|
86
|
+
"expires_at": page.expires_at.isoformat() if page.expires_at else None,
|
|
87
|
+
"created_at": page.created_at.isoformat() if page.created_at else None,
|
|
88
|
+
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
@mcp.tool()
|
|
93
93
|
def update_html(slug: str, html: str, owner_key: str, title: str | None = None) -> dict[str, Any]:
|
|
94
|
-
"""Replace the HTML for an existing
|
|
94
|
+
"""Replace the HTML for an existing page. Requires the original owner_key.
|
|
95
95
|
|
|
96
96
|
Args:
|
|
97
|
-
slug: The
|
|
97
|
+
slug: The page's short identifier.
|
|
98
98
|
html: The new HTML body.
|
|
99
99
|
owner_key: The owner key returned at publish time.
|
|
100
100
|
title: Optional new title.
|
|
101
101
|
"""
|
|
102
102
|
with _client() as c:
|
|
103
103
|
try:
|
|
104
|
-
|
|
104
|
+
page = c.update(slug, html, owner_key=owner_key, title=title)
|
|
105
105
|
except AuthError as exc:
|
|
106
106
|
raise RuntimeError(f"invalid owner_key for '{slug}'") from exc
|
|
107
107
|
except NotFoundError as exc:
|
|
108
|
-
raise RuntimeError(f"
|
|
108
|
+
raise RuntimeError(f"page '{slug}' not found") from exc
|
|
109
109
|
except HTMLShipError as exc:
|
|
110
110
|
raise RuntimeError(f"htmlship update failed: {exc}") from exc
|
|
111
111
|
return {
|
|
112
|
-
"url":
|
|
113
|
-
"slug":
|
|
114
|
-
"updated_at":
|
|
112
|
+
"url": page.url,
|
|
113
|
+
"slug": page.slug,
|
|
114
|
+
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -12,8 +12,8 @@ class Base(DeclarativeBase):
|
|
|
12
12
|
pass
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
class
|
|
16
|
-
__tablename__ = "
|
|
15
|
+
class Page(Base):
|
|
16
|
+
__tablename__ = "pages"
|
|
17
17
|
|
|
18
18
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
19
19
|
UUID(as_uuid=True),
|
|
@@ -53,8 +53,8 @@ class Paste(Base):
|
|
|
53
53
|
__table_args__ = (
|
|
54
54
|
CheckConstraint("sandbox_mode IN ('strict', 'relaxed')", name="ck_sandbox_mode"),
|
|
55
55
|
CheckConstraint("size_bytes <= 10485760", name="ck_size_bytes"),
|
|
56
|
-
Index("
|
|
57
|
-
Index("
|
|
58
|
-
Index("
|
|
59
|
-
Index("
|
|
56
|
+
Index("idx_pages_slug", "slug"),
|
|
57
|
+
Index("idx_pages_expires_at", "expires_at"),
|
|
58
|
+
Index("idx_pages_parent_slug", "parent_slug"),
|
|
59
|
+
Index("idx_pages_created_at", "created_at"),
|
|
60
60
|
)
|
|
@@ -10,7 +10,7 @@ from starlette.staticfiles import StaticFiles
|
|
|
10
10
|
from . import __version__
|
|
11
11
|
from .config import get_settings
|
|
12
12
|
from .middleware import HostRoutingMiddleware
|
|
13
|
-
from .routers import meta,
|
|
13
|
+
from .routers import meta, pages, view
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@asynccontextmanager
|
|
@@ -31,7 +31,7 @@ def create_app() -> FastAPI:
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
app.include_router(meta.router)
|
|
34
|
-
app.include_router(
|
|
34
|
+
app.include_router(pages.router)
|
|
35
35
|
|
|
36
36
|
# Landing-page static assets, served from /static/* on landing/api hosts.
|
|
37
37
|
web_dir = Path(__file__).resolve().parents[2] / "web"
|
|
@@ -8,13 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
8
8
|
|
|
9
9
|
from ..config import Settings, get_settings
|
|
10
10
|
from ..database import get_session
|
|
11
|
-
from ..db_models.
|
|
12
|
-
from ..schemas.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
from ..db_models.page import Page
|
|
12
|
+
from ..schemas.pages import (
|
|
13
|
+
PageCreate,
|
|
14
|
+
PageCreateResponse,
|
|
15
|
+
PageMetadata,
|
|
16
|
+
PageUpdate,
|
|
17
|
+
PageUpdateResponse,
|
|
18
18
|
)
|
|
19
19
|
from ..security import (
|
|
20
20
|
generate_owner_key,
|
|
@@ -33,18 +33,18 @@ def _view_url(settings: Settings, slug: str) -> str:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
async def _slug_exists(session: AsyncSession, slug: str) -> bool:
|
|
36
|
-
res = await session.execute(select(
|
|
36
|
+
res = await session.execute(select(Page.id).where(Page.slug == slug))
|
|
37
37
|
return res.first() is not None
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
async def _load_active(session: AsyncSession, slug: str) ->
|
|
41
|
-
res = await session.execute(select(
|
|
42
|
-
|
|
43
|
-
if
|
|
44
|
-
raise HTTPException(status_code=404, detail="
|
|
45
|
-
if
|
|
46
|
-
raise HTTPException(status_code=404, detail="
|
|
47
|
-
return
|
|
40
|
+
async def _load_active(session: AsyncSession, slug: str) -> Page:
|
|
41
|
+
res = await session.execute(select(Page).where(Page.slug == slug))
|
|
42
|
+
page = res.scalar_one_or_none()
|
|
43
|
+
if page is None or page.deleted_at is not None:
|
|
44
|
+
raise HTTPException(status_code=404, detail="page not found")
|
|
45
|
+
if page.expires_at is not None and page.expires_at <= datetime.now(UTC):
|
|
46
|
+
raise HTTPException(status_code=404, detail="page expired")
|
|
47
|
+
return page
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _require_owner_key(x_owner_key: str | None) -> str:
|
|
@@ -54,17 +54,17 @@ def _require_owner_key(x_owner_key: str | None) -> str:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
@router.post(
|
|
57
|
-
"/
|
|
57
|
+
"/pages",
|
|
58
58
|
status_code=status.HTTP_201_CREATED,
|
|
59
|
-
response_model=
|
|
59
|
+
response_model=PageCreateResponse,
|
|
60
60
|
)
|
|
61
|
-
async def
|
|
62
|
-
payload:
|
|
61
|
+
async def create_page(
|
|
62
|
+
payload: PageCreate,
|
|
63
63
|
request: Request,
|
|
64
64
|
settings: Settings = Depends(get_settings),
|
|
65
65
|
session: AsyncSession = Depends(get_session),
|
|
66
|
-
) ->
|
|
67
|
-
return await
|
|
66
|
+
) -> PageCreateResponse:
|
|
67
|
+
return await _create_page_impl(
|
|
68
68
|
payload=payload,
|
|
69
69
|
parent_override=payload.parent_slug,
|
|
70
70
|
settings=settings,
|
|
@@ -73,18 +73,18 @@ async def create_paste(
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
@router.post(
|
|
76
|
-
"/
|
|
76
|
+
"/pages/{slug}/version",
|
|
77
77
|
status_code=status.HTTP_201_CREATED,
|
|
78
|
-
response_model=
|
|
78
|
+
response_model=PageCreateResponse,
|
|
79
79
|
)
|
|
80
80
|
async def create_version(
|
|
81
81
|
slug: str,
|
|
82
|
-
payload:
|
|
82
|
+
payload: PageCreate,
|
|
83
83
|
settings: Settings = Depends(get_settings),
|
|
84
84
|
session: AsyncSession = Depends(get_session),
|
|
85
|
-
) ->
|
|
85
|
+
) -> PageCreateResponse:
|
|
86
86
|
parent = await _load_active(session, slug)
|
|
87
|
-
return await
|
|
87
|
+
return await _create_page_impl(
|
|
88
88
|
payload=payload,
|
|
89
89
|
parent_override=parent.slug,
|
|
90
90
|
settings=settings,
|
|
@@ -92,13 +92,13 @@ async def create_version(
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
async def
|
|
95
|
+
async def _create_page_impl(
|
|
96
96
|
*,
|
|
97
|
-
payload:
|
|
97
|
+
payload: PageCreate,
|
|
98
98
|
parent_override: str | None,
|
|
99
99
|
settings: Settings,
|
|
100
100
|
session: AsyncSession,
|
|
101
|
-
) ->
|
|
101
|
+
) -> PageCreateResponse:
|
|
102
102
|
html_bytes = payload.html.encode("utf-8")
|
|
103
103
|
if len(html_bytes) > settings.max_payload_bytes:
|
|
104
104
|
raise HTTPException(status_code=413, detail="html exceeds size limit")
|
|
@@ -122,7 +122,7 @@ async def _create_paste_impl(
|
|
|
122
122
|
elif settings.default_expires_in_minutes:
|
|
123
123
|
expires_at = datetime.now(UTC) + timedelta(minutes=settings.default_expires_in_minutes)
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
page = Page(
|
|
126
126
|
slug=slug,
|
|
127
127
|
blob_key="", # set after we know the id
|
|
128
128
|
title=payload.title,
|
|
@@ -133,101 +133,101 @@ async def _create_paste_impl(
|
|
|
133
133
|
size_bytes=len(html_bytes),
|
|
134
134
|
parent_slug=parent_override,
|
|
135
135
|
)
|
|
136
|
-
session.add(
|
|
136
|
+
session.add(page)
|
|
137
137
|
await session.flush()
|
|
138
138
|
|
|
139
|
-
blob_key = make_blob_key(
|
|
140
|
-
|
|
139
|
+
blob_key = make_blob_key(page.id)
|
|
140
|
+
page.blob_key = blob_key
|
|
141
141
|
|
|
142
142
|
store = get_blob_store()
|
|
143
143
|
await store.put(blob_key, html_bytes)
|
|
144
144
|
|
|
145
145
|
await session.commit()
|
|
146
|
-
await session.refresh(
|
|
146
|
+
await session.refresh(page)
|
|
147
147
|
|
|
148
|
-
return
|
|
149
|
-
slug=
|
|
150
|
-
url=_view_url(settings,
|
|
148
|
+
return PageCreateResponse(
|
|
149
|
+
slug=page.slug,
|
|
150
|
+
url=_view_url(settings, page.slug),
|
|
151
151
|
owner_key=owner_key,
|
|
152
|
-
expires_at=
|
|
153
|
-
created_at=
|
|
152
|
+
expires_at=page.expires_at,
|
|
153
|
+
created_at=page.created_at,
|
|
154
154
|
)
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
@router.get("/
|
|
158
|
-
async def
|
|
157
|
+
@router.get("/pages/{slug}", response_model=PageMetadata)
|
|
158
|
+
async def get_page_metadata(
|
|
159
159
|
slug: str,
|
|
160
160
|
settings: Settings = Depends(get_settings),
|
|
161
161
|
session: AsyncSession = Depends(get_session),
|
|
162
|
-
) ->
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
slug=
|
|
166
|
-
title=
|
|
167
|
-
url=_view_url(settings,
|
|
168
|
-
expires_at=
|
|
169
|
-
created_at=
|
|
170
|
-
updated_at=
|
|
171
|
-
view_count=
|
|
172
|
-
size_bytes=
|
|
173
|
-
has_password=
|
|
174
|
-
parent_slug=
|
|
162
|
+
) -> PageMetadata:
|
|
163
|
+
page = await _load_active(session, slug)
|
|
164
|
+
return PageMetadata(
|
|
165
|
+
slug=page.slug,
|
|
166
|
+
title=page.title,
|
|
167
|
+
url=_view_url(settings, page.slug),
|
|
168
|
+
expires_at=page.expires_at,
|
|
169
|
+
created_at=page.created_at,
|
|
170
|
+
updated_at=page.updated_at,
|
|
171
|
+
view_count=page.view_count,
|
|
172
|
+
size_bytes=page.size_bytes,
|
|
173
|
+
has_password=page.password_hash is not None,
|
|
174
|
+
parent_slug=page.parent_slug,
|
|
175
175
|
)
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
@router.patch("/
|
|
179
|
-
async def
|
|
178
|
+
@router.patch("/pages/{slug}", response_model=PageUpdateResponse)
|
|
179
|
+
async def update_page(
|
|
180
180
|
slug: str,
|
|
181
|
-
payload:
|
|
181
|
+
payload: PageUpdate,
|
|
182
182
|
x_owner_key: str | None = Header(default=None, alias="X-Owner-Key"),
|
|
183
183
|
settings: Settings = Depends(get_settings),
|
|
184
184
|
session: AsyncSession = Depends(get_session),
|
|
185
|
-
) ->
|
|
185
|
+
) -> PageUpdateResponse:
|
|
186
186
|
owner_key = _require_owner_key(x_owner_key)
|
|
187
|
-
|
|
188
|
-
if not verify_owner_key(owner_key,
|
|
187
|
+
page = await _load_active(session, slug)
|
|
188
|
+
if not verify_owner_key(owner_key, page.owner_key_hash):
|
|
189
189
|
raise HTTPException(status_code=403, detail="invalid owner key")
|
|
190
190
|
|
|
191
191
|
html_bytes = payload.html.encode("utf-8")
|
|
192
192
|
if len(html_bytes) > settings.max_payload_bytes:
|
|
193
193
|
raise HTTPException(status_code=413, detail="html exceeds size limit")
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
page.size_bytes = len(html_bytes)
|
|
196
196
|
if payload.title is not None:
|
|
197
|
-
|
|
197
|
+
page.title = payload.title
|
|
198
198
|
|
|
199
199
|
store = get_blob_store()
|
|
200
|
-
await store.put(
|
|
200
|
+
await store.put(page.blob_key, html_bytes)
|
|
201
201
|
|
|
202
202
|
await session.commit()
|
|
203
|
-
await session.refresh(
|
|
203
|
+
await session.refresh(page)
|
|
204
204
|
|
|
205
|
-
return
|
|
206
|
-
slug=
|
|
207
|
-
url=_view_url(settings,
|
|
208
|
-
expires_at=
|
|
209
|
-
updated_at=
|
|
205
|
+
return PageUpdateResponse(
|
|
206
|
+
slug=page.slug,
|
|
207
|
+
url=_view_url(settings, page.slug),
|
|
208
|
+
expires_at=page.expires_at,
|
|
209
|
+
updated_at=page.updated_at,
|
|
210
210
|
)
|
|
211
211
|
|
|
212
212
|
|
|
213
213
|
@router.delete(
|
|
214
|
-
"/
|
|
214
|
+
"/pages/{slug}",
|
|
215
215
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
216
216
|
response_class=Response,
|
|
217
217
|
)
|
|
218
|
-
async def
|
|
218
|
+
async def delete_page(
|
|
219
219
|
slug: str,
|
|
220
220
|
x_owner_key: str | None = Header(default=None, alias="X-Owner-Key"),
|
|
221
221
|
session: AsyncSession = Depends(get_session),
|
|
222
222
|
) -> Response:
|
|
223
223
|
owner_key = _require_owner_key(x_owner_key)
|
|
224
|
-
|
|
225
|
-
if not verify_owner_key(owner_key,
|
|
224
|
+
page = await _load_active(session, slug)
|
|
225
|
+
if not verify_owner_key(owner_key, page.owner_key_hash):
|
|
226
226
|
raise HTTPException(status_code=403, detail="invalid owner key")
|
|
227
|
-
|
|
227
|
+
page.deleted_at = datetime.now(UTC)
|
|
228
228
|
store = get_blob_store()
|
|
229
229
|
try:
|
|
230
|
-
await store.delete(
|
|
230
|
+
await store.delete(page.blob_key)
|
|
231
231
|
except Exception:
|
|
232
232
|
pass
|
|
233
233
|
await session.commit()
|
|
@@ -10,7 +10,7 @@ from starlette.responses import HTMLResponse
|
|
|
10
10
|
|
|
11
11
|
from ..config import Settings, get_settings
|
|
12
12
|
from ..database import get_session
|
|
13
|
-
from ..db_models.
|
|
13
|
+
from ..db_models.page import Page
|
|
14
14
|
from ..security import verify_password
|
|
15
15
|
from ..storage import get_blob_store
|
|
16
16
|
|
|
@@ -55,7 +55,7 @@ PASSWORD_FORM_TEMPLATE = """<!doctype html>
|
|
|
55
55
|
</head>
|
|
56
56
|
<body>
|
|
57
57
|
<main>
|
|
58
|
-
<h1>This
|
|
58
|
+
<h1>This page is password-protected.</h1>
|
|
59
59
|
<p>Enter the password to view it.</p>
|
|
60
60
|
{error_html}
|
|
61
61
|
<form method="post">
|
|
@@ -69,7 +69,7 @@ PASSWORD_FORM_TEMPLATE = """<!doctype html>
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def _serializer(settings: Settings) -> URLSafeSerializer:
|
|
72
|
-
return URLSafeSerializer(settings.secret_key, salt="htmlship-
|
|
72
|
+
return URLSafeSerializer(settings.secret_key, salt="htmlship-page-session")
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def _cookie_name(slug: str) -> str:
|
|
@@ -108,27 +108,27 @@ def _password_form(slug: str, error: str | None = None) -> HTMLResponse:
|
|
|
108
108
|
return HTMLResponse(body, headers=headers)
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
async def _load_active_view(session: AsyncSession, slug: str) ->
|
|
111
|
+
async def _load_active_view(session: AsyncSession, slug: str) -> Page:
|
|
112
112
|
from sqlalchemy import select
|
|
113
113
|
|
|
114
|
-
res = await session.execute(select(
|
|
115
|
-
|
|
116
|
-
if
|
|
114
|
+
res = await session.execute(select(Page).where(Page.slug == slug))
|
|
115
|
+
page = res.scalar_one_or_none()
|
|
116
|
+
if page is None or page.deleted_at is not None:
|
|
117
117
|
raise HTTPException(status_code=404, detail="not found")
|
|
118
|
-
if
|
|
118
|
+
if page.expires_at is not None and page.expires_at <= datetime.now(UTC):
|
|
119
119
|
raise HTTPException(status_code=404, detail="expired")
|
|
120
|
-
return
|
|
120
|
+
return page
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
async def _serve_html(
|
|
123
|
+
async def _serve_html(page: Page) -> HTMLResponse:
|
|
124
124
|
store = get_blob_store()
|
|
125
|
-
data = await store.get(
|
|
125
|
+
data = await store.get(page.blob_key)
|
|
126
126
|
return HTMLResponse(data, headers=SECURITY_HEADERS)
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
async def _bump_view_count(session: AsyncSession,
|
|
129
|
+
async def _bump_view_count(session: AsyncSession, page_id) -> None:
|
|
130
130
|
await session.execute(
|
|
131
|
-
update(
|
|
131
|
+
update(Page).where(Page.id == page_id).values(view_count=Page.view_count + 1)
|
|
132
132
|
)
|
|
133
133
|
await session.commit()
|
|
134
134
|
|
|
@@ -140,13 +140,13 @@ async def view_get(
|
|
|
140
140
|
settings: Settings = Depends(get_settings),
|
|
141
141
|
session: AsyncSession = Depends(get_session),
|
|
142
142
|
) -> Response:
|
|
143
|
-
|
|
143
|
+
page = await _load_active_view(session, slug)
|
|
144
144
|
|
|
145
|
-
if
|
|
145
|
+
if page.password_hash and not _has_valid_session(request, slug, settings):
|
|
146
146
|
return _password_form(slug)
|
|
147
147
|
|
|
148
|
-
response = await _serve_html(
|
|
149
|
-
await _bump_view_count(session,
|
|
148
|
+
response = await _serve_html(page)
|
|
149
|
+
await _bump_view_count(session, page.id)
|
|
150
150
|
return response
|
|
151
151
|
|
|
152
152
|
|
|
@@ -158,18 +158,18 @@ async def view_post_password(
|
|
|
158
158
|
settings: Settings = Depends(get_settings),
|
|
159
159
|
session: AsyncSession = Depends(get_session),
|
|
160
160
|
) -> Response:
|
|
161
|
-
|
|
161
|
+
page = await _load_active_view(session, slug)
|
|
162
162
|
|
|
163
|
-
if not
|
|
163
|
+
if not page.password_hash:
|
|
164
164
|
# No password set; redirect-equivalent: just serve.
|
|
165
|
-
response = await _serve_html(
|
|
166
|
-
await _bump_view_count(session,
|
|
165
|
+
response = await _serve_html(page)
|
|
166
|
+
await _bump_view_count(session, page.id)
|
|
167
167
|
return response
|
|
168
168
|
|
|
169
|
-
if not verify_password(password,
|
|
169
|
+
if not verify_password(password, page.password_hash):
|
|
170
170
|
return _password_form(slug, error="Wrong password.")
|
|
171
171
|
|
|
172
|
-
response = await _serve_html(
|
|
172
|
+
response = await _serve_html(page)
|
|
173
173
|
_set_session_cookie(response, slug, settings)
|
|
174
|
-
await _bump_view_count(session,
|
|
174
|
+
await _bump_view_count(session, page.id)
|
|
175
175
|
return response
|
|
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, field_validator
|
|
|
8
8
|
MAX_EXPIRES_IN_MINUTES = 60 * 24 * 7 # 7 days
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class PageCreate(BaseModel):
|
|
12
12
|
html: str = Field(..., min_length=0, max_length=10 * 1024 * 1024)
|
|
13
13
|
title: str | None = Field(default=None, max_length=200)
|
|
14
14
|
password: str | None = Field(default=None, max_length=128)
|
|
@@ -28,12 +28,12 @@ class PasteCreate(BaseModel):
|
|
|
28
28
|
return v
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
class
|
|
31
|
+
class PageUpdate(BaseModel):
|
|
32
32
|
html: str = Field(..., min_length=0, max_length=10 * 1024 * 1024)
|
|
33
33
|
title: str | None = Field(default=None, max_length=200)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
class
|
|
36
|
+
class PageCreateResponse(BaseModel):
|
|
37
37
|
slug: str
|
|
38
38
|
url: str
|
|
39
39
|
owner_key: str
|
|
@@ -41,7 +41,7 @@ class PasteCreateResponse(BaseModel):
|
|
|
41
41
|
created_at: datetime
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
class
|
|
44
|
+
class PageMetadata(BaseModel):
|
|
45
45
|
slug: str
|
|
46
46
|
title: str | None
|
|
47
47
|
url: str
|
|
@@ -54,7 +54,7 @@ class PasteMetadata(BaseModel):
|
|
|
54
54
|
parent_slug: str | None
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
class
|
|
57
|
+
class PageUpdateResponse(BaseModel):
|
|
58
58
|
slug: str
|
|
59
59
|
url: str
|
|
60
60
|
expires_at: datetime | None
|
|
@@ -11,9 +11,9 @@ from botocore.config import Config
|
|
|
11
11
|
from .config import get_settings
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def make_blob_key(
|
|
14
|
+
def make_blob_key(page_id: uuid.UUID, *, when: datetime | None = None) -> str:
|
|
15
15
|
when = when or datetime.now(UTC)
|
|
16
|
-
return f"
|
|
16
|
+
return f"pages/{when.year:04d}/{when.month:02d}/{page_id}.html"
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class BlobStore(Protocol):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|