pste-server 0.1.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.
- pste_server-0.1.0/.gitignore +12 -0
- pste_server-0.1.0/Dockerfile +13 -0
- pste_server-0.1.0/PKG-INFO +147 -0
- pste_server-0.1.0/README.md +120 -0
- pste_server-0.1.0/examples/Caddyfile +15 -0
- pste_server-0.1.0/examples/cloud.md +256 -0
- pste_server-0.1.0/examples/compose-cloudflare.yml +21 -0
- pste_server-0.1.0/examples/compose-postgres.yml +28 -0
- pste_server-0.1.0/examples/compose-sqlite.yml +13 -0
- pste_server-0.1.0/examples/nginx.conf +20 -0
- pste_server-0.1.0/pyproject.toml +37 -0
- pste_server-0.1.0/src/pste_server/__init__.py +0 -0
- pste_server-0.1.0/src/pste_server/admin.py +209 -0
- pste_server-0.1.0/src/pste_server/config.py +41 -0
- pste_server-0.1.0/src/pste_server/id_gen.py +50 -0
- pste_server-0.1.0/src/pste_server/main.py +398 -0
- pste_server-0.1.0/src/pste_server/models.py +75 -0
- pste_server-0.1.0/src/pste_server/ratelimit.py +67 -0
- pste_server-0.1.0/src/pste_server/reaper.py +212 -0
- pste_server-0.1.0/src/pste_server/storage.py +71 -0
- pste_server-0.1.0/src/pste_server/validation.py +67 -0
- pste_server-0.1.0/tests/__init__.py +0 -0
- pste_server-0.1.0/tests/conftest.py +97 -0
- pste_server-0.1.0/tests/test_admin.py +237 -0
- pste_server-0.1.0/tests/test_api.py +708 -0
- pste_server-0.1.0/tests/test_integration.py +249 -0
- pste_server-0.1.0/tests/test_ratelimit.py +84 -0
- pste_server-0.1.0/tests/test_reaper.py +768 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
LABEL org.opencontainers.image.source="https://github.com/crognlie/pste"
|
|
3
|
+
LABEL org.opencontainers.image.licenses="MIT"
|
|
4
|
+
RUN useradd -m -u 1000 pste
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
COPY . .
|
|
7
|
+
RUN pip install --no-cache-dir ".[gcs]" \
|
|
8
|
+
&& mkdir -p /app/data \
|
|
9
|
+
&& chown pste:pste /app/data
|
|
10
|
+
USER pste
|
|
11
|
+
EXPOSE 8000
|
|
12
|
+
ENV SQLITE_PATH=/app/data/pste.db
|
|
13
|
+
CMD ["pste-server"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pste-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-hosted paste server (sprunge-inspired) with API key auth, soft-delete, and configurable storage
|
|
5
|
+
Project-URL: Homepage, https://github.com/crognlie/pste
|
|
6
|
+
Project-URL: Repository, https://github.com/crognlie/pste
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: cli,paste,pastebin,self-hosted,sprunge
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: fastapi>=0.110
|
|
12
|
+
Requires-Dist: pygments>=2.17
|
|
13
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
14
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
15
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
16
|
+
Provides-Extra: gcs
|
|
17
|
+
Requires-Dist: google-cloud-storage>=2.0; extra == 'gcs'
|
|
18
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'gcs'
|
|
19
|
+
Provides-Extra: postgresql
|
|
20
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'postgresql'
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: httpx2>=0.28; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
25
|
+
Requires-Dist: requests>=2.31; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pste-server
|
|
29
|
+
|
|
30
|
+
Self-hosted paste server inspired by [sprunge](http://sprunge.us). Pastes are world-readable; creating requires an API key.
|
|
31
|
+
|
|
32
|
+
**HTTPS is required in production.** API keys appear in the `Authorization` header and in the `/?key=<key>` query string used by the web form — both are exposed over plain HTTP. pste-server speaks plain HTTP on port 8000; use a reverse proxy or tunnel to terminate TLS. See [`examples/Caddyfile`](examples/Caddyfile) and [`examples/compose-cloudflare.yml`](examples/compose-cloudflare.yml).
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
BASE_URL=https://pste.example.com pste-server
|
|
39
|
+
|
|
40
|
+
# Add your first API key — prints the bookmark URL directly
|
|
41
|
+
pste-admin key add --user alice
|
|
42
|
+
# -> https://pste.example.com/?key=AbCd1234...
|
|
43
|
+
|
|
44
|
+
# Set PSTE_URL on the client to that URL, then:
|
|
45
|
+
echo "hello" | pste
|
|
46
|
+
# -> https://pste.example.com/AB1234
|
|
47
|
+
pste AB1234
|
|
48
|
+
# -> hello
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See [`examples/`](examples/) for Docker Compose, Cloudflare Tunnel, and cloud deployment configurations.
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
GET / Help page (add ?key=<key> for the paste web form)
|
|
57
|
+
POST / Create paste (requires Authorization: Bearer <key>)
|
|
58
|
+
GET /<id> Fetch paste as plain text
|
|
59
|
+
GET /<id>?<lang> Fetch with Pygments syntax highlighting + copy button
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Creating pastes:**
|
|
63
|
+
|
|
64
|
+
| Field | Type | Description |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `pste` | string | Paste content (required) |
|
|
67
|
+
| `lang` | string | Pygments lexer name for syntax highlighting |
|
|
68
|
+
| `auto_detect` | `1` | Auto-detect language (Pygments, >0.5 confidence threshold) |
|
|
69
|
+
| `single_view` | `1` | Delete after first read |
|
|
70
|
+
| `expires_at` | ISO8601 UTC | Absolute expiry timestamp |
|
|
71
|
+
| `expires_in_n` | integer | Expiry amount (used with `expires_in_unit`) |
|
|
72
|
+
| `expires_in_unit` | H/D/W/M | Expiry unit: hours, days, weeks, minutes |
|
|
73
|
+
|
|
74
|
+
`lang` and `auto_detect` are mutually exclusive — if `lang` is provided, auto-detection is skipped. `expires_at` and `expires_in_n`/`expires_in_unit` are also mutually exclusive.
|
|
75
|
+
|
|
76
|
+
**Fetching pastes:**
|
|
77
|
+
|
|
78
|
+
- `GET /<id>` — always plain text, regardless of stored lang
|
|
79
|
+
- `GET /<id>?<lang>` — Pygments-highlighted HTML with table line numbers (line numbers have `user-select: none` so Ctrl-A copies only code) and a Copy button
|
|
80
|
+
- `GET /<id>?none` — plain text (same as bare GET)
|
|
81
|
+
|
|
82
|
+
## Web form
|
|
83
|
+
|
|
84
|
+
Open `/?key=<key>` in a browser to use the paste web form. The key is embedded in the bookmark URL; paste it from `pste-admin key add` output. The form includes:
|
|
85
|
+
|
|
86
|
+
- Textarea for paste content
|
|
87
|
+
- Single-view checkbox
|
|
88
|
+
- Expiry controls (number + H/D/W/M dropdown)
|
|
89
|
+
- Language dropdown (auto-detect default, 27 common lexers)
|
|
90
|
+
|
|
91
|
+
When submitted with **language auto-detect** (default), if Pygments identifies the language with >0.5 confidence the result page shows both the plain URL and a highlighted URL. When a **specific language** is selected the result shows only the highlighted `?<lang>` URL.
|
|
92
|
+
|
|
93
|
+
## Managing API keys
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Add a key (prints the full bookmark URL)
|
|
97
|
+
pste-admin key add --user alice --notes "personal laptop"
|
|
98
|
+
|
|
99
|
+
# Specify your own key value (must be [A-Za-z0-9])
|
|
100
|
+
pste-admin key add --key MySecretKey --user alice
|
|
101
|
+
|
|
102
|
+
# List all keys
|
|
103
|
+
pste-admin key list
|
|
104
|
+
|
|
105
|
+
# Revoke by key value (immediate, no confirmation)
|
|
106
|
+
pste-admin key revoke --key <key-value>
|
|
107
|
+
|
|
108
|
+
# Revoke all keys for a user or matching notes (lists keys, requires y to confirm)
|
|
109
|
+
pste-admin key revoke --user alice
|
|
110
|
+
pste-admin key revoke --notes "old laptop"
|
|
111
|
+
|
|
112
|
+
# Update key metadata
|
|
113
|
+
pste-admin key set --user alice --notes "rotated 2026-07"
|
|
114
|
+
pste-admin key set --key <key-value> --disabled true
|
|
115
|
+
|
|
116
|
+
# List pastes (shows ID, created, lang, key, deleted status)
|
|
117
|
+
pste-admin paste list --user alice
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Keys take effect immediately — no restart required.
|
|
121
|
+
|
|
122
|
+
## Environment variables
|
|
123
|
+
|
|
124
|
+
| Variable | Default | Description |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `BASE_URL` | `http://localhost:8000` | Public URL used in paste links and key add output |
|
|
127
|
+
| `PORT` | `8000` | Listen port |
|
|
128
|
+
| `STORAGE_BACKEND` | `sqlite` | `sqlite`, `postgresql`, or `gcs` |
|
|
129
|
+
| `SQLITE_PATH` | `./data/pste.db` | SQLite DB path |
|
|
130
|
+
| `DATABASE_URL` | — | PostgreSQL connection string |
|
|
131
|
+
| `GCS_BUCKET` | — | GCS bucket name |
|
|
132
|
+
| `MAX_PASTE_BYTES` | `1048576` | Max paste size (bytes) |
|
|
133
|
+
| `DARK_MODE` | `false` | Use `github-dark` as the highlight style; default (unset) uses `default` (light) |
|
|
134
|
+
| `HIGHLIGHT_STYLE` | — | Pin to any [Pygments style](https://pygments.org/styles/) name, ignoring `DARK_MODE` |
|
|
135
|
+
|
|
136
|
+
### Deletion
|
|
137
|
+
|
|
138
|
+
By default, expired and single-view pastes are **soft-deleted** — `deleted_at` is set and they become inaccessible, but the row is retained. The variables below control hard deletion.
|
|
139
|
+
|
|
140
|
+
| Variable | Default | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `DELETE_ON_EXPIRE` | `false` | Hard-delete immediately on expiry |
|
|
143
|
+
| `DELETE_ON_SINGLE_VIEW` | `false` | Hard-delete immediately on first view |
|
|
144
|
+
| `DELETE_AFTER_EXPIRE` | `7D` | Hard-delete soft-deleted expired rows after this duration |
|
|
145
|
+
| `DELETE_AFTER_SINGLE_VIEW` | `7D` | Hard-delete soft-deleted single-view rows after this duration |
|
|
146
|
+
|
|
147
|
+
Duration format for `DELETE_AFTER_*`: integer + `H` (hours), `D` (days), `W` (weeks), or `M` (minutes). Set to empty string (`DELETE_AFTER_EXPIRE=`) to disable deferred hard-deletion entirely. `DELETE_AFTER_*` runs on a 30-minute cycle, independently of `DELETE_ON_*`.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# pste-server
|
|
2
|
+
|
|
3
|
+
Self-hosted paste server inspired by [sprunge](http://sprunge.us). Pastes are world-readable; creating requires an API key.
|
|
4
|
+
|
|
5
|
+
**HTTPS is required in production.** API keys appear in the `Authorization` header and in the `/?key=<key>` query string used by the web form — both are exposed over plain HTTP. pste-server speaks plain HTTP on port 8000; use a reverse proxy or tunnel to terminate TLS. See [`examples/Caddyfile`](examples/Caddyfile) and [`examples/compose-cloudflare.yml`](examples/compose-cloudflare.yml).
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
BASE_URL=https://pste.example.com pste-server
|
|
12
|
+
|
|
13
|
+
# Add your first API key — prints the bookmark URL directly
|
|
14
|
+
pste-admin key add --user alice
|
|
15
|
+
# -> https://pste.example.com/?key=AbCd1234...
|
|
16
|
+
|
|
17
|
+
# Set PSTE_URL on the client to that URL, then:
|
|
18
|
+
echo "hello" | pste
|
|
19
|
+
# -> https://pste.example.com/AB1234
|
|
20
|
+
pste AB1234
|
|
21
|
+
# -> hello
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
See [`examples/`](examples/) for Docker Compose, Cloudflare Tunnel, and cloud deployment configurations.
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
GET / Help page (add ?key=<key> for the paste web form)
|
|
30
|
+
POST / Create paste (requires Authorization: Bearer <key>)
|
|
31
|
+
GET /<id> Fetch paste as plain text
|
|
32
|
+
GET /<id>?<lang> Fetch with Pygments syntax highlighting + copy button
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Creating pastes:**
|
|
36
|
+
|
|
37
|
+
| Field | Type | Description |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `pste` | string | Paste content (required) |
|
|
40
|
+
| `lang` | string | Pygments lexer name for syntax highlighting |
|
|
41
|
+
| `auto_detect` | `1` | Auto-detect language (Pygments, >0.5 confidence threshold) |
|
|
42
|
+
| `single_view` | `1` | Delete after first read |
|
|
43
|
+
| `expires_at` | ISO8601 UTC | Absolute expiry timestamp |
|
|
44
|
+
| `expires_in_n` | integer | Expiry amount (used with `expires_in_unit`) |
|
|
45
|
+
| `expires_in_unit` | H/D/W/M | Expiry unit: hours, days, weeks, minutes |
|
|
46
|
+
|
|
47
|
+
`lang` and `auto_detect` are mutually exclusive — if `lang` is provided, auto-detection is skipped. `expires_at` and `expires_in_n`/`expires_in_unit` are also mutually exclusive.
|
|
48
|
+
|
|
49
|
+
**Fetching pastes:**
|
|
50
|
+
|
|
51
|
+
- `GET /<id>` — always plain text, regardless of stored lang
|
|
52
|
+
- `GET /<id>?<lang>` — Pygments-highlighted HTML with table line numbers (line numbers have `user-select: none` so Ctrl-A copies only code) and a Copy button
|
|
53
|
+
- `GET /<id>?none` — plain text (same as bare GET)
|
|
54
|
+
|
|
55
|
+
## Web form
|
|
56
|
+
|
|
57
|
+
Open `/?key=<key>` in a browser to use the paste web form. The key is embedded in the bookmark URL; paste it from `pste-admin key add` output. The form includes:
|
|
58
|
+
|
|
59
|
+
- Textarea for paste content
|
|
60
|
+
- Single-view checkbox
|
|
61
|
+
- Expiry controls (number + H/D/W/M dropdown)
|
|
62
|
+
- Language dropdown (auto-detect default, 27 common lexers)
|
|
63
|
+
|
|
64
|
+
When submitted with **language auto-detect** (default), if Pygments identifies the language with >0.5 confidence the result page shows both the plain URL and a highlighted URL. When a **specific language** is selected the result shows only the highlighted `?<lang>` URL.
|
|
65
|
+
|
|
66
|
+
## Managing API keys
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Add a key (prints the full bookmark URL)
|
|
70
|
+
pste-admin key add --user alice --notes "personal laptop"
|
|
71
|
+
|
|
72
|
+
# Specify your own key value (must be [A-Za-z0-9])
|
|
73
|
+
pste-admin key add --key MySecretKey --user alice
|
|
74
|
+
|
|
75
|
+
# List all keys
|
|
76
|
+
pste-admin key list
|
|
77
|
+
|
|
78
|
+
# Revoke by key value (immediate, no confirmation)
|
|
79
|
+
pste-admin key revoke --key <key-value>
|
|
80
|
+
|
|
81
|
+
# Revoke all keys for a user or matching notes (lists keys, requires y to confirm)
|
|
82
|
+
pste-admin key revoke --user alice
|
|
83
|
+
pste-admin key revoke --notes "old laptop"
|
|
84
|
+
|
|
85
|
+
# Update key metadata
|
|
86
|
+
pste-admin key set --user alice --notes "rotated 2026-07"
|
|
87
|
+
pste-admin key set --key <key-value> --disabled true
|
|
88
|
+
|
|
89
|
+
# List pastes (shows ID, created, lang, key, deleted status)
|
|
90
|
+
pste-admin paste list --user alice
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Keys take effect immediately — no restart required.
|
|
94
|
+
|
|
95
|
+
## Environment variables
|
|
96
|
+
|
|
97
|
+
| Variable | Default | Description |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `BASE_URL` | `http://localhost:8000` | Public URL used in paste links and key add output |
|
|
100
|
+
| `PORT` | `8000` | Listen port |
|
|
101
|
+
| `STORAGE_BACKEND` | `sqlite` | `sqlite`, `postgresql`, or `gcs` |
|
|
102
|
+
| `SQLITE_PATH` | `./data/pste.db` | SQLite DB path |
|
|
103
|
+
| `DATABASE_URL` | — | PostgreSQL connection string |
|
|
104
|
+
| `GCS_BUCKET` | — | GCS bucket name |
|
|
105
|
+
| `MAX_PASTE_BYTES` | `1048576` | Max paste size (bytes) |
|
|
106
|
+
| `DARK_MODE` | `false` | Use `github-dark` as the highlight style; default (unset) uses `default` (light) |
|
|
107
|
+
| `HIGHLIGHT_STYLE` | — | Pin to any [Pygments style](https://pygments.org/styles/) name, ignoring `DARK_MODE` |
|
|
108
|
+
|
|
109
|
+
### Deletion
|
|
110
|
+
|
|
111
|
+
By default, expired and single-view pastes are **soft-deleted** — `deleted_at` is set and they become inaccessible, but the row is retained. The variables below control hard deletion.
|
|
112
|
+
|
|
113
|
+
| Variable | Default | Description |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `DELETE_ON_EXPIRE` | `false` | Hard-delete immediately on expiry |
|
|
116
|
+
| `DELETE_ON_SINGLE_VIEW` | `false` | Hard-delete immediately on first view |
|
|
117
|
+
| `DELETE_AFTER_EXPIRE` | `7D` | Hard-delete soft-deleted expired rows after this duration |
|
|
118
|
+
| `DELETE_AFTER_SINGLE_VIEW` | `7D` | Hard-delete soft-deleted single-view rows after this duration |
|
|
119
|
+
|
|
120
|
+
Duration format for `DELETE_AFTER_*`: integer + `H` (hours), `D` (days), `W` (weeks), or `M` (minutes). Set to empty string (`DELETE_AFTER_EXPIRE=`) to disable deferred hard-deletion entirely. `DELETE_AFTER_*` runs on a 30-minute cycle, independently of `DELETE_ON_*`.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Cloud deployment
|
|
2
|
+
|
|
3
|
+
## GCP: Cloud Run + Cloud SQL + GCS
|
|
4
|
+
|
|
5
|
+
Complete walkthrough from zero to a running pste instance. All commands use
|
|
6
|
+
the `gcloud` and `gsutil` CLIs. The only step that requires the Cloud Console
|
|
7
|
+
UI is creating the service account and downloading its JSON key — everything
|
|
8
|
+
else runs from a terminal.
|
|
9
|
+
|
|
10
|
+
### Prerequisites
|
|
11
|
+
|
|
12
|
+
- Google Cloud project with billing enabled
|
|
13
|
+
- `gcloud` CLI installed (`gcloud auth login`, `gcloud config set project YOUR_PROJECT`)
|
|
14
|
+
- **In Cloud Console:** IAM & Admin → Service Accounts → Create Service Account
|
|
15
|
+
named `pste` with no roles (we assign roles below) → Keys → Add Key → JSON.
|
|
16
|
+
Save the downloaded file as `pste-sa-key.json`.
|
|
17
|
+
|
|
18
|
+
### 0. Set variables
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export PROJECT_ID=$(gcloud config get-value project)
|
|
22
|
+
export REGION=us-central1
|
|
23
|
+
export SA_EMAIL="pste@${PROJECT_ID}.iam.gserviceaccount.com"
|
|
24
|
+
export SA_KEY=/path/to/pste-sa-key.json
|
|
25
|
+
export BUCKET="${PROJECT_ID}-pste-pastes"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 1. Enable APIs
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gcloud services enable \
|
|
32
|
+
run.googleapis.com \
|
|
33
|
+
sqladmin.googleapis.com \
|
|
34
|
+
storage.googleapis.com \
|
|
35
|
+
secretmanager.googleapis.com \
|
|
36
|
+
sql-component.googleapis.com
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Create Cloud Storage bucket
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gcloud storage buckets create gs://$BUCKET \
|
|
43
|
+
--location=$REGION \
|
|
44
|
+
--uniform-bucket-level-access
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. Create Cloud SQL (PostgreSQL) instance
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gcloud sql instances create pste-db \
|
|
51
|
+
--database-version=POSTGRES_16 \
|
|
52
|
+
--tier=db-f1-micro \
|
|
53
|
+
--edition=ENTERPRISE \
|
|
54
|
+
--region=$REGION
|
|
55
|
+
|
|
56
|
+
gcloud sql databases create pste --instance=pste-db
|
|
57
|
+
|
|
58
|
+
DB_PASS=$(openssl rand -base64 24 | tr -d '/+=')
|
|
59
|
+
gcloud sql users create pste --instance=pste-db --password="$DB_PASS"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> **Note:** `db-f1-micro` requires `--edition=ENTERPRISE`. New projects default
|
|
63
|
+
> to ENTERPRISE_PLUS, which only supports the `db-perf-optimized-N-*` tiers.
|
|
64
|
+
|
|
65
|
+
### 4. Grant service account permissions
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Read/write paste content in GCS
|
|
69
|
+
gcloud storage buckets add-iam-policy-binding gs://$BUCKET \
|
|
70
|
+
--member="serviceAccount:${SA_EMAIL}" \
|
|
71
|
+
--role="roles/storage.objectAdmin"
|
|
72
|
+
|
|
73
|
+
# Connect to Cloud SQL
|
|
74
|
+
gcloud projects add-iam-policy-binding $PROJECT_ID \
|
|
75
|
+
--member="serviceAccount:${SA_EMAIL}" \
|
|
76
|
+
--role="roles/cloudsql.client"
|
|
77
|
+
|
|
78
|
+
# Read secrets from Secret Manager
|
|
79
|
+
gcloud projects add-iam-policy-binding $PROJECT_ID \
|
|
80
|
+
--member="serviceAccount:${SA_EMAIL}" \
|
|
81
|
+
--role="roles/secretmanager.secretAccessor"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 5. Store database URL in Secret Manager
|
|
85
|
+
|
|
86
|
+
Cloud Run connects to Cloud SQL via a Unix socket at
|
|
87
|
+
`/cloudsql/PROJECT:REGION:INSTANCE`. The `@/pste` form (empty TCP host)
|
|
88
|
+
tells psycopg2 to use the `host` query parameter as the socket directory.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
DB_URL="postgresql://pste:${DB_PASS}@/pste?host=/cloudsql/${PROJECT_ID}:${REGION}:pste-db"
|
|
92
|
+
printf '%s' "$DB_URL" | gcloud secrets create pste-db-url --data-file=-
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 6. Push image to Artifact Registry
|
|
96
|
+
|
|
97
|
+
Cloud Run only accepts images from GCR, Artifact Registry, or Docker Hub —
|
|
98
|
+
not GHCR directly. Push the image to Artifact Registry first:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# Enable Artifact Registry and create a repository
|
|
102
|
+
gcloud services enable artifactregistry.googleapis.com
|
|
103
|
+
gcloud artifacts repositories create pste \
|
|
104
|
+
--repository-format=docker \
|
|
105
|
+
--location=$REGION
|
|
106
|
+
|
|
107
|
+
# Configure docker auth and push
|
|
108
|
+
gcloud auth configure-docker ${REGION}-docker.pkg.dev --quiet
|
|
109
|
+
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/pste/pste-server:latest"
|
|
110
|
+
docker pull ghcr.io/crognlie/pste:latest
|
|
111
|
+
docker tag ghcr.io/crognlie/pste:latest $IMAGE
|
|
112
|
+
docker push $IMAGE
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
> If you're deploying from a machine without Docker, you can also build with
|
|
116
|
+
> Cloud Build: `gcloud builds submit --tag $IMAGE server/`
|
|
117
|
+
|
|
118
|
+
### 7. Deploy to Cloud Run
|
|
119
|
+
|
|
120
|
+
Cloud Run needs to know `BASE_URL` (for paste link generation), but you don't
|
|
121
|
+
know the URL until after the first deploy. Deploy once to get the URL, then
|
|
122
|
+
update the service.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/pste/pste-server:latest"
|
|
126
|
+
|
|
127
|
+
gcloud run deploy pste-server \
|
|
128
|
+
--image $IMAGE \
|
|
129
|
+
--platform managed \
|
|
130
|
+
--region $REGION \
|
|
131
|
+
--service-account $SA_EMAIL \
|
|
132
|
+
--set-env-vars "STORAGE_BACKEND=gcs,GCS_BUCKET=${BUCKET}" \
|
|
133
|
+
--set-secrets "DATABASE_URL=pste-db-url:latest" \
|
|
134
|
+
--add-cloudsql-instances "${PROJECT_ID}:${REGION}:pste-db" \
|
|
135
|
+
--allow-unauthenticated \
|
|
136
|
+
--port 8000
|
|
137
|
+
|
|
138
|
+
# Get the assigned URL and redeploy with BASE_URL set
|
|
139
|
+
BASE_URL=$(gcloud run services describe pste-server \
|
|
140
|
+
--region $REGION --format 'value(status.url)')
|
|
141
|
+
|
|
142
|
+
gcloud run services update pste-server \
|
|
143
|
+
--region $REGION \
|
|
144
|
+
--update-env-vars "BASE_URL=${BASE_URL}"
|
|
145
|
+
|
|
146
|
+
echo "Service running at $BASE_URL"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 8. Add your first API key
|
|
150
|
+
|
|
151
|
+
Use the [Cloud SQL Auth Proxy](https://cloud.google.com/sql/docs/postgres/sql-proxy)
|
|
152
|
+
to open a local tunnel, then run `pste-admin` locally against it.
|
|
153
|
+
**Set `STORAGE_BACKEND=gcs`** so pste-admin connects to PostgreSQL instead of
|
|
154
|
+
defaulting to a local SQLite file.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Download the proxy (Linux x86-64; see docs for other platforms)
|
|
158
|
+
curl -Lo cloud-sql-proxy https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.1/cloud-sql-proxy.linux.amd64
|
|
159
|
+
chmod +x cloud-sql-proxy
|
|
160
|
+
|
|
161
|
+
# Start the proxy in the background
|
|
162
|
+
GOOGLE_APPLICATION_CREDENTIALS=$SA_KEY \
|
|
163
|
+
./cloud-sql-proxy "${PROJECT_ID}:${REGION}:pste-db" --port 5432 &
|
|
164
|
+
PROXY_PID=$!
|
|
165
|
+
|
|
166
|
+
# Run pste-admin key add (STORAGE_BACKEND=gcs selects the postgresql engine)
|
|
167
|
+
STORAGE_BACKEND=gcs \
|
|
168
|
+
DATABASE_URL="postgresql://pste:${DB_PASS}@localhost:5432/pste" \
|
|
169
|
+
GOOGLE_APPLICATION_CREDENTIALS=$SA_KEY \
|
|
170
|
+
pste-admin key add --user admin
|
|
171
|
+
# -> http://localhost:8000/?key=AbCd1234...
|
|
172
|
+
# (Replace localhost:8000 with $BASE_URL to get your bookmark URL)
|
|
173
|
+
|
|
174
|
+
kill $PROXY_PID
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 9. Test with the CLI
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
export PSTE_URL="${BASE_URL}/?key=AbCd1234..."
|
|
181
|
+
echo "hello from GCP" | pste
|
|
182
|
+
pste AB1234
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Testing GCS storage locally
|
|
188
|
+
|
|
189
|
+
To run the server test suite against a real GCS bucket (covers `storage.py`
|
|
190
|
+
GCS paths that can't be tested with mocks):
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# Start Cloud SQL Auth Proxy (see step 8 above)
|
|
194
|
+
GOOGLE_APPLICATION_CREDENTIALS=$SA_KEY \
|
|
195
|
+
./cloud-sql-proxy "${PROJECT_ID}:${REGION}:pste-db" --port 5432 &
|
|
196
|
+
|
|
197
|
+
cd server
|
|
198
|
+
pip install -e ".[postgresql,gcs]"
|
|
199
|
+
STORAGE_BACKEND=gcs \
|
|
200
|
+
GCS_BUCKET=$BUCKET \
|
|
201
|
+
DATABASE_URL="postgresql://pste:${DB_PASS}@localhost:5432/pste" \
|
|
202
|
+
GOOGLE_APPLICATION_CREDENTIALS=$SA_KEY \
|
|
203
|
+
python3 -m pytest
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## AWS: ECS Fargate + RDS + S3
|
|
209
|
+
|
|
210
|
+
### Infrastructure overview
|
|
211
|
+
|
|
212
|
+
| Component | AWS service |
|
|
213
|
+
|---|---|
|
|
214
|
+
| Container | ECS Fargate |
|
|
215
|
+
| Database | RDS PostgreSQL |
|
|
216
|
+
| Blob storage | S3 (via `[s3]` extra — see note) |
|
|
217
|
+
| Secrets | AWS Secrets Manager |
|
|
218
|
+
| DNS/TLS | ACM + Application Load Balancer |
|
|
219
|
+
|
|
220
|
+
> **Note:** The `[s3]` storage backend is not yet implemented. Use `postgresql`
|
|
221
|
+
> backend (store content in RDS) for AWS deployments.
|
|
222
|
+
|
|
223
|
+
### Example task definition environment
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"environment": [
|
|
228
|
+
{"name": "STORAGE_BACKEND", "value": "postgresql"},
|
|
229
|
+
{"name": "BASE_URL", "value": "https://pste.example.com"}
|
|
230
|
+
],
|
|
231
|
+
"secrets": [
|
|
232
|
+
{"name": "DATABASE_URL", "valueFrom": "arn:aws:secretsmanager:..."}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Azure: Container Apps + Azure Database for PostgreSQL + Azure Blob Storage
|
|
240
|
+
|
|
241
|
+
> **Note:** The Azure Blob Storage backend is not yet implemented. Use
|
|
242
|
+
> `postgresql` backend with Azure Database for PostgreSQL.
|
|
243
|
+
|
|
244
|
+
### Example
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
az containerapp create \
|
|
248
|
+
--name pste-server \
|
|
249
|
+
--resource-group pste-rg \
|
|
250
|
+
--image ghcr.io/crognlie/pste:latest \
|
|
251
|
+
--env-vars \
|
|
252
|
+
STORAGE_BACKEND=postgresql \
|
|
253
|
+
BASE_URL=https://pste.example.com \
|
|
254
|
+
DATABASE_URL=secretref:db-url \
|
|
255
|
+
--ingress external --target-port 8000
|
|
256
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Expose pste via Cloudflare Tunnel — no open ports or public IP required.
|
|
2
|
+
#
|
|
3
|
+
# Setup:
|
|
4
|
+
# 1. Create a tunnel at https://one.dash.cloudflare.com
|
|
5
|
+
# 2. Point the public hostname (e.g. pste.example.com) to http://pste:8000
|
|
6
|
+
# 3. Set TUNNEL_TOKEN below from the dashboard
|
|
7
|
+
|
|
8
|
+
services:
|
|
9
|
+
pste:
|
|
10
|
+
image: ghcr.io/crognlie/pste:latest
|
|
11
|
+
environment:
|
|
12
|
+
BASE_URL: https://pste.example.com
|
|
13
|
+
# No ports: exposed — cloudflared reaches pste directly on the Docker network
|
|
14
|
+
|
|
15
|
+
cloudflared:
|
|
16
|
+
image: cloudflare/cloudflared:latest
|
|
17
|
+
command: tunnel --no-autoupdate run
|
|
18
|
+
environment:
|
|
19
|
+
TUNNEL_TOKEN: your-tunnel-token-here
|
|
20
|
+
depends_on:
|
|
21
|
+
- pste
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
services:
|
|
2
|
+
pste:
|
|
3
|
+
image: ghcr.io/crognlie/pste:latest
|
|
4
|
+
ports:
|
|
5
|
+
- "8000:8000"
|
|
6
|
+
environment:
|
|
7
|
+
STORAGE_BACKEND: postgresql
|
|
8
|
+
DATABASE_URL: postgresql://pste:changeme@db/pste
|
|
9
|
+
BASE_URL: https://pste.example.com
|
|
10
|
+
depends_on:
|
|
11
|
+
db:
|
|
12
|
+
condition: service_healthy
|
|
13
|
+
|
|
14
|
+
db:
|
|
15
|
+
image: postgres:16-alpine
|
|
16
|
+
environment:
|
|
17
|
+
POSTGRES_USER: pste
|
|
18
|
+
POSTGRES_PASSWORD: changeme
|
|
19
|
+
POSTGRES_DB: pste
|
|
20
|
+
volumes:
|
|
21
|
+
- pg-data:/var/lib/postgresql/data
|
|
22
|
+
healthcheck:
|
|
23
|
+
test: ["CMD-SHELL", "pg_isready -U pste"]
|
|
24
|
+
interval: 5s
|
|
25
|
+
retries: 10
|
|
26
|
+
|
|
27
|
+
volumes:
|
|
28
|
+
pg-data:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Nginx reverse proxy for pste-server.
|
|
2
|
+
# Omits query strings from access logs so /?key=<key> isn't logged.
|
|
3
|
+
|
|
4
|
+
server {
|
|
5
|
+
listen 443 ssl;
|
|
6
|
+
server_name pste.example.com;
|
|
7
|
+
|
|
8
|
+
# TLS config omitted — use certbot or your preferred method
|
|
9
|
+
|
|
10
|
+
log_format pste '$remote_addr - $remote_user [$time_local] '
|
|
11
|
+
'"$request_method $uri $server_protocol" $status';
|
|
12
|
+
access_log /var/log/nginx/pste.log pste;
|
|
13
|
+
|
|
14
|
+
location / {
|
|
15
|
+
proxy_pass http://127.0.0.1:8000;
|
|
16
|
+
proxy_set_header Host $host;
|
|
17
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
18
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pste-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Self-hosted paste server (sprunge-inspired) with API key auth, soft-delete, and configurable storage"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["pastebin", "paste", "sprunge", "self-hosted", "cli"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"fastapi>=0.110",
|
|
15
|
+
"python-multipart>=0.0.9",
|
|
16
|
+
"uvicorn[standard]>=0.29",
|
|
17
|
+
"sqlalchemy>=2.0",
|
|
18
|
+
"pygments>=2.17",
|
|
19
|
+
"click>=8.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/crognlie/pste"
|
|
24
|
+
Repository = "https://github.com/crognlie/pste"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
postgresql = ["psycopg2-binary>=2.9"]
|
|
28
|
+
# gcs includes psycopg2-binary because GCS deployments use PostgreSQL for metadata
|
|
29
|
+
gcs = ["google-cloud-storage>=2.0", "psycopg2-binary>=2.9"]
|
|
30
|
+
test = ["pytest>=8.0", "httpx2>=0.28", "pytest-timeout>=2.3", "requests>=2.31"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
pste-server = "pste_server.main:run"
|
|
34
|
+
pste-admin = "pste_server.admin:cli"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/pste_server"]
|