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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ data/
5
+ dist/
6
+ .venv/
7
+ *.egg-info/
8
+ *.egg
9
+ .pytest_cache/
10
+ build/
11
+ *.coverage
12
+ .coverage
@@ -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,15 @@
1
+ pste.example.com {
2
+ # Omit query strings from logs so /?key=<key> isn't recorded
3
+ log {
4
+ format filter {
5
+ wrap json
6
+ fields {
7
+ request>uri replace {
8
+ regexp "\\?.*$" ""
9
+ }
10
+ }
11
+ }
12
+ }
13
+
14
+ reverse_proxy localhost:8000
15
+ }
@@ -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,13 @@
1
+ services:
2
+ pste:
3
+ image: ghcr.io/crognlie/pste:latest
4
+ ports:
5
+ - "8000:8000"
6
+ volumes:
7
+ - pste-data:/app/data
8
+ environment:
9
+ BASE_URL: https://pste.example.com
10
+ MAX_PASTE_BYTES: 1048576
11
+
12
+ volumes:
13
+ pste-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"]