htmlship 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ Metadata-Version: 2.4
2
+ Name: htmlship
3
+ Version: 0.1.0
4
+ Summary: Host and share HTML pages from LLMs and coding agents in one line.
5
+ Project-URL: Homepage, https://htmlship.com
6
+ Project-URL: Repository, https://github.com/htmlship/htmlship
7
+ Author: HTMLShip
8
+ License: MIT
9
+ Keywords: agent,hosting,html,llm,mcp,paste
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: click<9,>=8.1
17
+ Requires-Dist: httpx<0.28,>=0.27
18
+ Provides-Extra: cli
19
+ Requires-Dist: pyperclip<2,>=1.9; extra == 'cli'
20
+ Provides-Extra: dev
21
+ Requires-Dist: aiosqlite>=0.22.1; extra == 'dev'
22
+ Requires-Dist: greenlet>=3.5.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio<0.25,>=0.24; extra == 'dev'
24
+ Requires-Dist: pytest-cov<6,>=5; extra == 'dev'
25
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
26
+ Requires-Dist: ruff<0.8,>=0.7; extra == 'dev'
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp<2,>=1.0; extra == 'mcp'
29
+ Provides-Extra: server
30
+ Requires-Dist: alembic<2,>=1.13; extra == 'server'
31
+ Requires-Dist: argon2-cffi<24,>=23; extra == 'server'
32
+ Requires-Dist: asyncpg<0.31,>=0.30; extra == 'server'
33
+ Requires-Dist: boto3<2,>=1.35; extra == 'server'
34
+ Requires-Dist: fastapi<0.116,>=0.115; extra == 'server'
35
+ Requires-Dist: itsdangerous<3,>=2.2; extra == 'server'
36
+ Requires-Dist: nanoid<3,>=2; extra == 'server'
37
+ Requires-Dist: pydantic-settings<3,>=2.5; extra == 'server'
38
+ Requires-Dist: pydantic<3,>=2; extra == 'server'
39
+ Requires-Dist: python-multipart<0.1,>=0.0.12; extra == 'server'
40
+ Requires-Dist: slowapi<0.2,>=0.1.9; extra == 'server'
41
+ Requires-Dist: sqlalchemy<3,>=2.0; extra == 'server'
42
+ Requires-Dist: uvicorn[standard]<0.33,>=0.32; extra == 'server'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # HTMLShip
46
+
47
+ Host and share HTML pages from LLMs and coding agents in one line.
48
+
49
+ HTMLShip has three surfaces:
50
+
51
+ - a public Python library and CLI (`htmlship`)
52
+ - a FastAPI service for creating, updating, deleting, and viewing HTML pastes
53
+ - a stdio MCP server (`htmlship-mcp`) for agent clients
54
+
55
+ ```bash
56
+ pip install htmlship
57
+ ```
58
+
59
+ ```python
60
+ import htmlship
61
+
62
+ paste = htmlship.publish("<h1>Hello</h1>", title="Demo", expires_in=3600)
63
+ print(paste.url)
64
+ print(paste.owner_key) # save this to update or delete the paste later
65
+ ```
66
+
67
+ ```bash
68
+ curl -X POST https://api.htmlship.com/api/v1/pastes \
69
+ -H "Content-Type: application/json" \
70
+ -d '{"html":"<h1>Hello</h1>","title":"Demo"}'
71
+ ```
72
+
73
+ See [`htmlship-implementation-spec.md`](./htmlship-implementation-spec.md) for the product spec and [`DEPLOY.md`](./DEPLOY.md) for the production runbook.
74
+
75
+ ## Python Library
76
+
77
+ The module-level helpers use `https://api.htmlship.com` by default. Override with `HTMLSHIP_API_URL` or `htmlship.configure(base_url=...)`.
78
+
79
+ ```python
80
+ import htmlship
81
+
82
+ paste = htmlship.publish(
83
+ "<h1>Hello</h1>",
84
+ title="Demo",
85
+ password="optional-password",
86
+ expires_in=86400,
87
+ )
88
+
89
+ fresh = htmlship.get(paste.slug)
90
+ updated = htmlship.update(paste.slug, "<h1>Updated</h1>", owner_key=paste.owner_key)
91
+ htmlship.delete(updated.slug, owner_key=paste.owner_key)
92
+ ```
93
+
94
+ `owner_key` is returned only when a paste is created. It is required for updates and deletes, and the API does not return it again from metadata calls.
95
+
96
+ ## CLI
97
+
98
+ ```bash
99
+ htmlship publish report.html
100
+ cat report.html | htmlship publish -
101
+ htmlship publish --file report.html --title "Q4 Report" --expires-in 3600
102
+
103
+ htmlship get <slug>
104
+ htmlship update <slug> updated.html
105
+ htmlship delete <slug>
106
+ htmlship list-mine
107
+ ```
108
+
109
+ The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pastes 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.
110
+
111
+ ## API
112
+
113
+ Base URL: `https://api.htmlship.com`.
114
+
115
+ | Method | Path | Description |
116
+ | --- | --- | --- |
117
+ | `GET` | `/health` | Health check with service version. |
118
+ | `GET` | `/version` | Service version. |
119
+ | `POST` | `/api/v1/pastes` | Create a paste. |
120
+ | `GET` | `/api/v1/pastes/{slug}` | Fetch paste metadata. |
121
+ | `PATCH` | `/api/v1/pastes/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
122
+ | `DELETE` | `/api/v1/pastes/{slug}` | Soft-delete a paste. Requires `X-Owner-Key`. |
123
+ | `POST` | `/api/v1/pastes/{slug}/version` | Create a new paste linked to an existing parent slug. |
124
+
125
+ Create payload:
126
+
127
+ ```json
128
+ {
129
+ "html": "<h1>Hello</h1>",
130
+ "title": "Optional title",
131
+ "password": "optional password",
132
+ "expires_in": 3600,
133
+ "parent_slug": "optional-parent",
134
+ "sandbox_mode": "strict"
135
+ }
136
+ ```
137
+
138
+ Notes:
139
+
140
+ - `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
141
+ - Payloads are limited to 10 MiB by default.
142
+ - `expires_in` is in seconds and must be between 60 seconds and 365 days.
143
+ - `sandbox_mode` accepts `strict` or `relaxed`; the current view headers use the strict CSP.
144
+ - Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
145
+
146
+ ## Rendering
147
+
148
+ Rendered HTML is served from `view.htmlship.com/{slug}` with strict security headers:
149
+
150
+ - `Content-Security-Policy: default-src 'none'`
151
+ - images from `data:` and `https:`
152
+ - inline styles plus HTTPS stylesheets
153
+ - HTTPS/data fonts and HTTPS media
154
+ - `X-Content-Type-Options: nosniff`
155
+ - `Referrer-Policy: no-referrer`
156
+ - restrictive `Permissions-Policy`
157
+
158
+ The app routes by `Host` header:
159
+
160
+ - `htmlship.com` serves the landing page
161
+ - `api.htmlship.com` serves the API and landing assets
162
+ - `view.htmlship.com/{slug}` serves sandboxed HTML
163
+
164
+ For local development without DNS, append `?_host=view.htmlship.com` (or your configured view host) to spoof the host header, for example:
165
+
166
+ ```bash
167
+ curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
168
+ ```
169
+
170
+ ## MCP Server
171
+
172
+ `htmlship-mcp` is a stdio MCP server exposing three tools:
173
+
174
+ - `publish_html`
175
+ - `fetch_html`
176
+ - `update_html`
177
+
178
+ All HTTP traffic goes through the public Python client. Configure the endpoint with `HTMLSHIP_API_URL`.
179
+
180
+ ### Claude Desktop
181
+
182
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows:
183
+
184
+ ```json
185
+ {
186
+ "mcpServers": {
187
+ "htmlship": {
188
+ "command": "htmlship-mcp",
189
+ "env": {
190
+ "HTMLSHIP_API_URL": "https://api.htmlship.com"
191
+ }
192
+ }
193
+ }
194
+ }
195
+ ```
196
+
197
+ Restart Claude Desktop. Then ask: `publish this HTML: <h1>test</h1>`.
198
+
199
+ ### Claude Code
200
+
201
+ Edit `~/.claude.json` or use `claude mcp add`:
202
+
203
+ ```json
204
+ {
205
+ "mcpServers": {
206
+ "htmlship": {
207
+ "type": "stdio",
208
+ "command": "htmlship-mcp",
209
+ "env": {
210
+ "HTMLSHIP_API_URL": "https://api.htmlship.com"
211
+ }
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## Local Development
218
+
219
+ Requirements:
220
+
221
+ - Python 3.12+
222
+ - [uv](https://docs.astral.sh/uv/)
223
+ - Docker
224
+
225
+ ```bash
226
+ # 1. Install dependencies (creates .venv)
227
+ uv sync --extra server --extra cli --extra mcp --extra dev
228
+
229
+ # 2. Start Postgres on localhost:5433
230
+ docker compose up -d postgres
231
+
232
+ # 3. Copy env and edit if needed
233
+ cp .env.example .env
234
+
235
+ # 4. Run migrations
236
+ uv run alembic upgrade head
237
+
238
+ # 5. Start the server
239
+ uv run uvicorn htmlship_server.main:app --reload
240
+
241
+ # 6. Health check
242
+ curl http://localhost:8000/health
243
+ ```
244
+
245
+ Run tests and linting:
246
+
247
+ ```bash
248
+ uv run pytest
249
+ uv run ruff check .
250
+ ```
251
+
252
+ The test suite uses SQLite and a temporary local blob store. Local development uses Postgres metadata plus `./tmp/blobs/` for HTML blobs unless `SPACES_BUCKET` is configured.
253
+
254
+ ## Configuration
255
+
256
+ The server reads `.env` via Pydantic settings.
257
+
258
+ | Variable | Default | Purpose |
259
+ | --- | --- | --- |
260
+ | `DATABASE_URL` | `postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship` | Async SQLAlchemy database URL. |
261
+ | `PUBLIC_BASE_DOMAIN` | `htmlship.com` | Base domain used to derive host routing. |
262
+ | `API_BASE_URL` | `https://api.htmlship.com` | Public API URL setting. |
263
+ | `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in paste responses. |
264
+ | `LANDING_BASE_URL` | `https://htmlship.com` | Public landing URL. |
265
+ | `SPACES_BUCKET` | empty | If empty, use local blob storage; otherwise use DigitalOcean Spaces/S3. |
266
+ | `SPACES_REGION` | `nyc3` | Spaces/S3 region. |
267
+ | `SPACES_ENDPOINT_URL` | `https://nyc3.digitaloceanspaces.com` | Spaces/S3 endpoint. |
268
+ | `SPACES_ACCESS_KEY` / `SPACES_SECRET_KEY` | empty | Spaces/S3 credentials. |
269
+ | `SECRET_KEY` | development placeholder | Signs password-view session cookies. Use a strong value in production. |
270
+ | `ENVIRONMENT` | `development` | Enables API docs outside production and secure cookies in production. |
271
+ | `LOG_LEVEL` | `info` | Application log level. |
272
+ | `MAX_PAYLOAD_BYTES` | `10485760` | Server-side HTML size limit. |
273
+ | `DEFAULT_EXPIRES_IN_SECONDS` | empty | Optional default TTL for new pastes. |
274
+
275
+ ## Architecture
276
+
277
+ 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.
278
+
279
+ Postgres stores paste 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.
280
+
281
+ ## Project Layout
282
+
283
+ ```text
284
+ src/htmlship/ Public Python library + CLI
285
+ src/htmlship_server/ FastAPI app, API routers, storage, database models
286
+ src/htmlship_mcp/ MCP server (stdio)
287
+ web/ Static landing page
288
+ tests/ API, client, CLI, MCP, landing, and view tests
289
+ alembic/ Database migrations
290
+ deploy/ Production configs (nginx, systemd)
291
+ scripts/ Deploy and smoke-test scripts
292
+ ```
@@ -0,0 +1,29 @@
1
+ htmlship/__init__.py,sha256=XnwrjZOXH58DxoOo6CobfD0WfdxOmprDeGdOxqbBIlo,2360
2
+ htmlship/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ htmlship/cli.py,sha256=JwOMhqbxltApQbXP-sPnx3gmS9FlyquAE_bmcESAC7Y,9249
4
+ htmlship/client.py,sha256=eSjvdTiS94gwG_-jZwAYzRsrAwzP14DdwTdZQwwTCfY,6665
5
+ htmlship/exceptions.py,sha256=9P85j5FpjqT4s0z4fErCHfTnguWvbBXuv0UM3MtzjLc,775
6
+ htmlship/models.py,sha256=OdRhEshJZF6rXrprS4QcmfMtiYtnkaIUNlf0FkKwRYU,2238
7
+ htmlship_mcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
8
+ htmlship_mcp/server.py,sha256=GGYQ08X23HuPRaxyYtcsfHo5l6WEYZDVmS9rz_gtg4E,4059
9
+ htmlship_server/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
10
+ htmlship_server/config.py,sha256=1wIeco_m22Kve-3Jt3h310MotfxJCf4a50FjfI2zHnw,1643
11
+ htmlship_server/database.py,sha256=WRBpVqYJmFkXMVniGDiaX9j5HTJDoUxHnZSftqxF7qw,832
12
+ htmlship_server/exceptions.py,sha256=fRR0Dz5PrFvrE8CfM5q53KED-1pr8VIGZ43ECW05-V0,160
13
+ htmlship_server/main.py,sha256=NZ95It_GSQEwuhv9THAjfdvN5PXL506NXo0BoKbUbF0,2218
14
+ htmlship_server/middleware.py,sha256=ZsCkv_AdSsU9hvl8HIHyna04VbZApyre-kJRMj-zfdI,4504
15
+ htmlship_server/security.py,sha256=DwPiC1VORPGnzjOhy8DtB0dWfDEbJQsdDwRMNfIGBmA,1037
16
+ htmlship_server/slugs.py,sha256=Gylvapqka6SSxfYkC1ZJEaQu5NKUtfTsgzHtTMDF__Q,1040
17
+ htmlship_server/storage.py,sha256=Mv7W2xCH9mlPzzshkU4aG8ArlO65YfKUR9hYrOGmLnI,3875
18
+ htmlship_server/db_models/__init__.py,sha256=gLN65iq3xYnEw9Rvhre88i3gw8QBMJMda_33sYncKDo,60
19
+ htmlship_server/db_models/paste.py,sha256=roX2MLMpZDoFNoaluB9AnbrutaiqVzVyFrbHlUSVdVk,2246
20
+ htmlship_server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ htmlship_server/routers/meta.py,sha256=axzAEOLxsD5gpCxrrXBDEUS6HEj2rGSdpT60F_9e_Q8,332
22
+ htmlship_server/routers/pastes.py,sha256=bLHWCc4C3do0NVNpRc96BpZGvQTuCjlmGoMYy4hn5YQ,7207
23
+ htmlship_server/routers/view.py,sha256=ZSDTK_qu59JVLhmjnA_LMCzlxTdeYtAn2gr8K5lEFBg,5582
24
+ htmlship_server/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ htmlship_server/schemas/pastes.py,sha256=4rqa31YzW7ezSWKme59Sq8GbyAnrqC2dRJo4PSVrkAs,1258
26
+ htmlship-0.1.0.dist-info/METADATA,sha256=PC0ju57I3jE3InMEhWnEIqgfVdJR_Znz8GHnGkloXTU,9664
27
+ htmlship-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
28
+ htmlship-0.1.0.dist-info/entry_points.txt,sha256=zsk7_AdOG5WKc_UCBw_BmBBEGC0ynruYWWjk_UlEmd0,87
29
+ htmlship-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ htmlship = htmlship.cli:main
3
+ htmlship-mcp = htmlship_mcp.server:main
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
htmlship_mcp/server.py ADDED
@@ -0,0 +1,123 @@
1
+ """HTMLShip MCP server.
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.
6
+
7
+ Configure the API endpoint via the HTMLSHIP_API_URL environment variable.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any
13
+
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ import htmlship
17
+ from htmlship.exceptions import AuthError, HTMLShipError, NotFoundError
18
+
19
+ mcp = FastMCP("htmlship")
20
+
21
+
22
+ def _client() -> htmlship.HTMLShipClient:
23
+ base_url = os.getenv("HTMLSHIP_API_URL")
24
+ return htmlship.HTMLShipClient(base_url=base_url)
25
+
26
+
27
+ @mcp.tool()
28
+ def publish_html(
29
+ html: str,
30
+ title: str | None = None,
31
+ expires_in_seconds: int | None = None,
32
+ ) -> dict[str, Any]:
33
+ """Publish an HTML document and get a public URL.
34
+
35
+ Args:
36
+ html: The HTML body to publish. May include inline CSS but scripts will
37
+ be blocked by Content Security Policy at view time.
38
+ title: Optional human-readable title.
39
+ expires_in_seconds: Optional TTL. If omitted, the paste is permanent
40
+ (subject to the server's default policy).
41
+
42
+ Returns:
43
+ A dict with keys: url, slug, owner_key, expires_at.
44
+ IMPORTANT: owner_key is the only credential to update or delete the
45
+ paste later. Save it.
46
+ """
47
+ with _client() as c:
48
+ try:
49
+ paste = c.publish(html, title=title, expires_in=expires_in_seconds)
50
+ except HTMLShipError as exc:
51
+ raise RuntimeError(f"htmlship publish failed: {exc}") from exc
52
+ return {
53
+ "url": paste.url,
54
+ "slug": paste.slug,
55
+ "owner_key": paste.owner_key,
56
+ "expires_at": paste.expires_at.isoformat() if paste.expires_at else None,
57
+ }
58
+
59
+
60
+ @mcp.tool()
61
+ def fetch_html(slug: str) -> dict[str, Any]:
62
+ """Fetch a paste's metadata.
63
+
64
+ NOTE: this returns metadata only. To view the rendered HTML, open the
65
+ `url` in a browser — the content is served from a sandboxed subdomain.
66
+
67
+ Args:
68
+ slug: The paste's short identifier.
69
+ """
70
+ with _client() as c:
71
+ try:
72
+ paste = c.get(slug)
73
+ except NotFoundError as exc:
74
+ raise RuntimeError(f"paste '{slug}' not found") from exc
75
+ except HTMLShipError as exc:
76
+ raise RuntimeError(f"htmlship fetch failed: {exc}") from exc
77
+ return {
78
+ "slug": paste.slug,
79
+ "url": paste.url,
80
+ "title": paste.title,
81
+ "view_count": paste.view_count,
82
+ "size_bytes": paste.size_bytes,
83
+ "has_password": paste.has_password,
84
+ "parent_slug": paste.parent_slug,
85
+ "expires_at": paste.expires_at.isoformat() if paste.expires_at else None,
86
+ "created_at": paste.created_at.isoformat() if paste.created_at else None,
87
+ "updated_at": paste.updated_at.isoformat() if paste.updated_at else None,
88
+ }
89
+
90
+
91
+ @mcp.tool()
92
+ def update_html(slug: str, html: str, owner_key: str, title: str | None = None) -> dict[str, Any]:
93
+ """Replace the HTML for an existing paste. Requires the original owner_key.
94
+
95
+ Args:
96
+ slug: The paste's short identifier.
97
+ html: The new HTML body.
98
+ owner_key: The owner key returned at publish time.
99
+ title: Optional new title.
100
+ """
101
+ with _client() as c:
102
+ try:
103
+ paste = c.update(slug, html, owner_key=owner_key, title=title)
104
+ except AuthError as exc:
105
+ raise RuntimeError(f"invalid owner_key for '{slug}'") from exc
106
+ except NotFoundError as exc:
107
+ raise RuntimeError(f"paste '{slug}' not found") from exc
108
+ except HTMLShipError as exc:
109
+ raise RuntimeError(f"htmlship update failed: {exc}") from exc
110
+ return {
111
+ "url": paste.url,
112
+ "slug": paste.slug,
113
+ "updated_at": paste.updated_at.isoformat() if paste.updated_at else None,
114
+ }
115
+
116
+
117
+ def main() -> None:
118
+ """Entry point for the `htmlship-mcp` console script (stdio transport)."""
119
+ mcp.run()
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(
11
+ env_file=".env",
12
+ env_file_encoding="utf-8",
13
+ extra="ignore",
14
+ case_sensitive=False,
15
+ )
16
+
17
+ environment: str = "development"
18
+ log_level: str = "info"
19
+
20
+ database_url: str = Field(
21
+ default="postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship"
22
+ )
23
+
24
+ public_base_domain: str = "htmlship.com"
25
+ api_base_url: str = "https://api.htmlship.com"
26
+ view_base_url: str = "https://view.htmlship.com"
27
+ landing_base_url: str = "https://htmlship.com"
28
+
29
+ spaces_bucket: str = ""
30
+ spaces_region: str = "nyc3"
31
+ spaces_endpoint_url: str = "https://nyc3.digitaloceanspaces.com"
32
+ spaces_access_key: str = ""
33
+ spaces_secret_key: str = ""
34
+
35
+ secret_key: str = "dev-secret-do-not-use-in-prod"
36
+
37
+ max_payload_bytes: int = 10 * 1024 * 1024
38
+ default_expires_in_seconds: int | None = None
39
+
40
+ @field_validator("default_expires_in_seconds", mode="before")
41
+ @classmethod
42
+ def _empty_to_none(cls, v):
43
+ if v == "" or v is None:
44
+ return None
45
+ return v
46
+
47
+ @property
48
+ def use_local_blob_store(self) -> bool:
49
+ return not self.spaces_bucket
50
+
51
+ @property
52
+ def view_host(self) -> str:
53
+ return f"view.{self.public_base_domain}"
54
+
55
+ @property
56
+ def api_host(self) -> str:
57
+ return f"api.{self.public_base_domain}"
58
+
59
+
60
+ @lru_cache
61
+ def get_settings() -> Settings:
62
+ return Settings()
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+
5
+ from sqlalchemy.ext.asyncio import (
6
+ AsyncSession,
7
+ async_sessionmaker,
8
+ create_async_engine,
9
+ )
10
+
11
+ from .config import get_settings
12
+
13
+ _settings = get_settings()
14
+
15
+
16
+ def _build_engine(url: str):
17
+ kwargs: dict = {"echo": False}
18
+ if "sqlite" not in url:
19
+ kwargs.update(pool_size=20, max_overflow=10, pool_pre_ping=True)
20
+ return create_async_engine(url, **kwargs)
21
+
22
+
23
+ engine = _build_engine(_settings.database_url)
24
+
25
+ SessionLocal = async_sessionmaker(
26
+ engine,
27
+ expire_on_commit=False,
28
+ class_=AsyncSession,
29
+ )
30
+
31
+
32
+ async def get_session() -> AsyncIterator[AsyncSession]:
33
+ async with SessionLocal() as session:
34
+ try:
35
+ yield session
36
+ except Exception:
37
+ await session.rollback()
38
+ raise
@@ -0,0 +1,3 @@
1
+ from .paste import Base, Paste
2
+
3
+ __all__ = ["Base", "Paste"]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from sqlalchemy import BigInteger, CheckConstraint, DateTime, Index, Integer, String, func
7
+ from sqlalchemy.dialects.postgresql import UUID
8
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
9
+
10
+
11
+ class Base(DeclarativeBase):
12
+ pass
13
+
14
+
15
+ class Paste(Base):
16
+ __tablename__ = "pastes"
17
+
18
+ id: Mapped[uuid.UUID] = mapped_column(
19
+ UUID(as_uuid=True),
20
+ primary_key=True,
21
+ default=uuid.uuid4,
22
+ server_default=func.gen_random_uuid(),
23
+ )
24
+ slug: Mapped[str] = mapped_column(String(12), unique=True, nullable=False)
25
+ blob_key: Mapped[str] = mapped_column(String(255), nullable=False)
26
+ title: Mapped[str | None] = mapped_column(nullable=True)
27
+ owner_key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
28
+ password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
29
+ sandbox_mode: Mapped[str] = mapped_column(
30
+ String(20), nullable=False, default="strict", server_default="strict"
31
+ )
32
+ expires_at: Mapped[datetime | None] = mapped_column(
33
+ DateTime(timezone=True), nullable=True
34
+ )
35
+ view_count: Mapped[int] = mapped_column(
36
+ BigInteger, nullable=False, default=0, server_default="0"
37
+ )
38
+ size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
39
+ parent_slug: Mapped[str | None] = mapped_column(String(12), nullable=True)
40
+ deleted_at: Mapped[datetime | None] = mapped_column(
41
+ DateTime(timezone=True), nullable=True
42
+ )
43
+ created_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True), nullable=False, server_default=func.now()
45
+ )
46
+ updated_at: Mapped[datetime] = mapped_column(
47
+ DateTime(timezone=True),
48
+ nullable=False,
49
+ server_default=func.now(),
50
+ onupdate=func.now(),
51
+ )
52
+
53
+ __table_args__ = (
54
+ CheckConstraint("sandbox_mode IN ('strict', 'relaxed')", name="ck_sandbox_mode"),
55
+ CheckConstraint("size_bytes <= 10485760", name="ck_size_bytes"),
56
+ Index("idx_pastes_slug", "slug"),
57
+ Index("idx_pastes_expires_at", "expires_at"),
58
+ Index("idx_pastes_parent_slug", "parent_slug"),
59
+ Index("idx_pastes_created_at", "created_at"),
60
+ )
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class StorageError(Exception):
5
+ """Raised when the blob store fails."""
6
+
7
+
8
+ class PasteNotFoundError(Exception):
9
+ pass