pste-server 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.
File without changes
pste_server/admin.py ADDED
@@ -0,0 +1,209 @@
1
+ import os
2
+ import re
3
+ import secrets
4
+ import string
5
+ import sys
6
+ from datetime import datetime
7
+
8
+ import click
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ from pste_server.models import Key, Paste, get_engine
12
+
13
+ _KEY_RE = re.compile(r"^[A-Za-z0-9]+$")
14
+ _KEY_ALPHABET = string.ascii_letters + string.digits
15
+
16
+
17
+ def _generate_key(length: int = 32) -> str:
18
+ return "".join(secrets.choice(_KEY_ALPHABET) for _ in range(length))
19
+
20
+
21
+ def _get_db():
22
+ Session = sessionmaker(bind=get_engine())
23
+ return Session()
24
+
25
+
26
+ def _fmt_dt(dt: datetime | None) -> str:
27
+ if dt is None:
28
+ return ""
29
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
30
+
31
+
32
+ @click.group()
33
+ def cli():
34
+ """pste server administration tool."""
35
+
36
+
37
+ @cli.group()
38
+ def key():
39
+ """Manage API keys."""
40
+
41
+
42
+ @key.command("add")
43
+ @click.option("--key", "key_val", default=None, help="Key value (generated if omitted; must be [A-Za-z0-9])")
44
+ @click.option("--user", default=None, help="Human identifier (name, email, etc.)")
45
+ @click.option("--notes", default=None, help="Freeform notes")
46
+ def key_add(key_val, user, notes):
47
+ """Generate and store a new API key."""
48
+ if key_val is not None:
49
+ if not _KEY_RE.match(key_val):
50
+ click.echo("Error: --key must contain only [A-Za-z0-9]", err=True)
51
+ sys.exit(1)
52
+ else:
53
+ key_val = _generate_key()
54
+ db = _get_db()
55
+ try:
56
+ if db.query(Key).filter(Key.key == key_val).first():
57
+ click.echo("Error: key already exists", err=True)
58
+ sys.exit(1)
59
+ k = Key(key=key_val, user=user, notes=notes, disabled=False)
60
+ db.add(k)
61
+ db.commit()
62
+ base_url = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
63
+ click.echo(f"{base_url}/?key={key_val}")
64
+ finally:
65
+ db.close()
66
+
67
+
68
+ @key.command("list")
69
+ def key_list():
70
+ """List all API keys."""
71
+ db = _get_db()
72
+ try:
73
+ keys = db.query(Key).order_by(Key.created_at).all()
74
+ if not keys:
75
+ click.echo("No keys found.")
76
+ return
77
+ fmt = "{:<44} {:<20} {:<8} {:<19} {}"
78
+ click.echo(fmt.format("KEY", "USER", "DISABLED", "CREATED", "NOTES"))
79
+ click.echo("-" * 100)
80
+ for k in keys:
81
+ click.echo(fmt.format(
82
+ k.key[:43],
83
+ (k.user or "")[:19],
84
+ "yes" if k.disabled else "no",
85
+ _fmt_dt(k.created_at),
86
+ k.notes or "",
87
+ ))
88
+ finally:
89
+ db.close()
90
+
91
+
92
+ @key.command("set")
93
+ @click.option("--key", "key_val", default=None, help="Match by key value")
94
+ @click.option("--user", "user_match", default=None, help="Match by user (updates all matching)")
95
+ @click.option("--set-user", default=None, help="New user value")
96
+ @click.option("--notes", default=None, help="New notes value")
97
+ @click.option("--disabled", default=None, type=click.Choice(["true", "false"]), help="Enable/disable key")
98
+ def key_set(key_val, user_match, set_user, notes, disabled):
99
+ """Modify key fields. Match by --key or --user."""
100
+ if not key_val and not user_match:
101
+ click.echo("Error: must provide --key or --user to match", err=True)
102
+ sys.exit(1)
103
+
104
+ db = _get_db()
105
+ try:
106
+ q = db.query(Key)
107
+ if key_val:
108
+ q = q.filter(Key.key == key_val)
109
+ if user_match:
110
+ q = q.filter(Key.user == user_match)
111
+ keys = q.all()
112
+ if not keys:
113
+ click.echo("No matching keys found.", err=True)
114
+ sys.exit(1)
115
+
116
+ for k in keys:
117
+ if set_user is not None:
118
+ k.user = set_user
119
+ if notes is not None:
120
+ k.notes = notes
121
+ if disabled is not None:
122
+ k.disabled = disabled == "true"
123
+ db.commit()
124
+ click.echo(f"Updated {len(keys)} key(s).")
125
+ finally:
126
+ db.close()
127
+
128
+
129
+ @key.command("revoke")
130
+ @click.option("--key", "key_val", default=None, help="Revoke key by exact value")
131
+ @click.option("--user", "user_match", default=None, help="Revoke all keys for this user (with confirmation)")
132
+ @click.option("--notes", "notes_match", default=None, help="Revoke all keys with these notes (with confirmation)")
133
+ def key_revoke(key_val, user_match, notes_match):
134
+ """Disable one or more keys (soft revoke)."""
135
+ if not any([key_val, user_match, notes_match]):
136
+ click.echo("Error: must provide --key, --user, or --notes", err=True)
137
+ sys.exit(1)
138
+
139
+ db = _get_db()
140
+ try:
141
+ q = db.query(Key)
142
+ if key_val:
143
+ q = q.filter(Key.key == key_val)
144
+ if user_match:
145
+ q = q.filter(Key.user == user_match)
146
+ if notes_match:
147
+ q = q.filter(Key.notes == notes_match)
148
+ keys = q.all()
149
+
150
+ if not keys:
151
+ click.echo("No matching keys found.", err=True)
152
+ sys.exit(1)
153
+
154
+ # Require confirmation when matching by user or notes (potentially bulk)
155
+ if user_match or notes_match:
156
+ click.echo(f"Keys to revoke ({len(keys)}):")
157
+ for k in keys:
158
+ click.echo(f" {k.key[:16]}... user={k.user or ''} notes={k.notes or ''}")
159
+ if not click.confirm(f"Revoke {len(keys)} key(s)?", default=False):
160
+ click.echo("Cancelled.")
161
+ return
162
+
163
+ for k in keys:
164
+ k.disabled = True
165
+ db.commit()
166
+ click.echo(f"Revoked {len(keys)} key(s).")
167
+ finally:
168
+ db.close()
169
+
170
+
171
+ @cli.group()
172
+ def paste():
173
+ """Manage pastes."""
174
+
175
+
176
+ @paste.command("list")
177
+ @click.option("--user", default=None, help="Filter by creating user")
178
+ @click.option("--key", "key_val", default=None, help="Filter by API key")
179
+ def paste_list(user, key_val):
180
+ """List pastes."""
181
+ db = _get_db()
182
+ try:
183
+ q = db.query(Paste)
184
+ if key_val:
185
+ q = q.filter(Paste.created_by == key_val)
186
+ if user:
187
+ q = q.join(Key, Paste.created_by == Key.key).filter(Key.user == user)
188
+ pastes = q.order_by(Paste.created_at.desc()).limit(200).all()
189
+ if not pastes:
190
+ click.echo("No pastes found.")
191
+ return
192
+ fmt = "{:<10} {:<19} {:<10} {:<44} {:<19} {}"
193
+ click.echo(fmt.format("ID", "CREATED", "LANG", "CREATED_BY", "DELETED", "REASON"))
194
+ click.echo("-" * 120)
195
+ for p in pastes:
196
+ click.echo(fmt.format(
197
+ p.id,
198
+ _fmt_dt(p.created_at),
199
+ p.lang or "",
200
+ (p.created_by or "")[:43],
201
+ _fmt_dt(p.deleted_at),
202
+ p.deleted_reason or "",
203
+ ))
204
+ finally:
205
+ db.close()
206
+
207
+
208
+ if __name__ == "__main__":
209
+ cli()
pste_server/config.py ADDED
@@ -0,0 +1,41 @@
1
+ import os
2
+ import re
3
+ from datetime import timedelta
4
+
5
+ _DURATION_RE = re.compile(r"^(\d+)([HMWDhmwd])$")
6
+ _UNIT_SECONDS = {"H": 3600, "D": 86400, "W": 7 * 86400, "M": 60}
7
+
8
+
9
+ def _bool_env(name: str, default: bool = False) -> bool:
10
+ return os.environ.get(name, str(default)).lower() in ("1", "true", "yes")
11
+
12
+
13
+ def _duration_env(name: str, default: str = "") -> timedelta | None:
14
+ val = os.environ.get(name, default).strip()
15
+ if not val:
16
+ return None
17
+ m = _DURATION_RE.match(val)
18
+ if not m:
19
+ raise ValueError(
20
+ f"Invalid {name} value {val!r} — expected e.g. 12H, 7D, 2W, 3M"
21
+ )
22
+ n, unit = int(m.group(1)), m.group(2).upper()
23
+ return timedelta(seconds=n * _UNIT_SECONDS[unit])
24
+
25
+
26
+ def hard_delete_on_expire() -> bool:
27
+ return _bool_env("DELETE_ON_EXPIRE", False)
28
+
29
+
30
+ def hard_delete_on_single_view() -> bool:
31
+ return _bool_env("DELETE_ON_SINGLE_VIEW", False)
32
+
33
+
34
+ def delete_after_expire() -> timedelta | None:
35
+ """Hard-delete soft-deleted expired pastes after this duration (independent of DELETE_ON_EXPIRE)."""
36
+ return _duration_env("DELETE_AFTER_EXPIRE", "7D")
37
+
38
+
39
+ def delete_after_single_view() -> timedelta | None:
40
+ """Hard-delete soft-deleted single-view pastes after this duration (independent of DELETE_ON_SINGLE_VIEW)."""
41
+ return _duration_env("DELETE_AFTER_SINGLE_VIEW", "7D")
pste_server/id_gen.py ADDED
@@ -0,0 +1,50 @@
1
+ import logging
2
+ import secrets
3
+ import string
4
+
5
+ ALPHABET = string.ascii_uppercase + string.digits # [A-Z0-9], 36 chars
6
+
7
+ _id_length_cache: int | None = None
8
+
9
+
10
+ def get_id_length(db) -> int:
11
+ global _id_length_cache
12
+ if _id_length_cache is not None:
13
+ return _id_length_cache
14
+ from pste_server.models import ServerState
15
+ row = db.query(ServerState).filter_by(key="id_length").first()
16
+ if row is None:
17
+ from sqlalchemy.exc import IntegrityError
18
+ try:
19
+ row = ServerState(key="id_length", value="6")
20
+ db.add(row)
21
+ db.commit()
22
+ except IntegrityError:
23
+ db.rollback()
24
+ row = db.query(ServerState).filter_by(key="id_length").first()
25
+ _id_length_cache = int(row.value)
26
+ return _id_length_cache
27
+
28
+
29
+ def bump_id_length_if_needed(total_paste_count: int, db) -> None:
30
+ global _id_length_cache
31
+ from pste_server.models import ServerState
32
+ length = get_id_length(db)
33
+ threshold = int((36 ** length) * 0.01)
34
+ if total_paste_count >= threshold:
35
+ new_length = length + 1
36
+ row = db.query(ServerState).filter_by(key="id_length").first()
37
+ if row:
38
+ row.value = str(new_length)
39
+ else:
40
+ db.add(ServerState(key="id_length", value=str(new_length)))
41
+ db.commit()
42
+ _id_length_cache = new_length
43
+ logging.warning(
44
+ "Paste count %d reached 1%% of 36^%d (%d); bumping ID length to %d",
45
+ total_paste_count, length, threshold, new_length,
46
+ )
47
+
48
+
49
+ def generate_id(length: int) -> str:
50
+ return "".join(secrets.choice(ALPHABET) for _ in range(length))
pste_server/main.py ADDED
@@ -0,0 +1,398 @@
1
+ import html as _html
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+ from datetime import datetime, timezone
5
+
6
+ from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request
7
+ from fastapi.responses import HTMLResponse, PlainTextResponse, Response
8
+ from pygments import highlight
9
+ from pygments.formatters import HtmlFormatter
10
+ from pygments.lexers import get_lexer_by_name, guess_lexer
11
+ from pygments.util import ClassNotFound
12
+ from sqlalchemy import delete as sa_delete, update as sa_update
13
+ from sqlalchemy.exc import IntegrityError
14
+ from sqlalchemy.orm import Session
15
+
16
+ from pste_server.config import hard_delete_on_single_view
17
+ from pste_server.id_gen import generate_id, get_id_length
18
+ from pste_server.models import Key, Paste, get_engine, get_session
19
+ from pste_server.reaper import schedule_paste_expiry, start_reaper, stop_reaper
20
+ from pste_server.storage import get_storage
21
+ from pste_server.validation import (
22
+ ALLOWED_POST_FIELDS,
23
+ validate_content,
24
+ validate_expires_at,
25
+ validate_expires_in,
26
+ validate_lang,
27
+ )
28
+
29
+ BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
30
+
31
+ _DARK_MODE = os.environ.get("DARK_MODE", "").lower() in ("1", "true", "yes")
32
+ HIGHLIGHT_STYLE = os.environ.get("HIGHLIGHT_STYLE", "github-dark" if _DARK_MODE else "default")
33
+
34
+
35
+ @asynccontextmanager
36
+ async def lifespan(app: FastAPI):
37
+ get_engine()
38
+ start_reaper()
39
+ yield
40
+ stop_reaper()
41
+
42
+
43
+ app = FastAPI(lifespan=lifespan)
44
+
45
+
46
+ @app.middleware("http")
47
+ async def add_source_header(request, call_next):
48
+ response = await call_next(request)
49
+ response.headers["X-Source"] = "https://github.com/crognlie/pste"
50
+ return response
51
+
52
+
53
+ @app.middleware("http")
54
+ async def rate_limit(request: Request, call_next):
55
+ from pste_server.ratelimit import is_allowed
56
+ if not is_allowed(request):
57
+ return Response("Too Many Requests\n", status_code=429, media_type="text/plain")
58
+ return await call_next(request)
59
+
60
+
61
+ def _auth_key(authorization: str | None, db: Session) -> Key:
62
+ if not authorization or not authorization.startswith("Bearer "):
63
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
64
+ token = authorization[len("Bearer "):]
65
+ key = db.query(Key).filter(Key.key == token).first()
66
+ if not key:
67
+ raise HTTPException(status_code=401, detail="Invalid API key")
68
+ if key.disabled:
69
+ raise HTTPException(status_code=403, detail="API key is disabled")
70
+ return key
71
+
72
+
73
+ def _render_highlighted(content: str, lang: str, headers: dict | None = None) -> Response:
74
+ try:
75
+ lexer = get_lexer_by_name(lang)
76
+ except ClassNotFound:
77
+ lexer = get_lexer_by_name("text")
78
+ formatter = HtmlFormatter(style=HIGHLIGHT_STYLE, lineanchors="n", linenos="table")
79
+ code_html = highlight(content, lexer, formatter)
80
+ style_defs = formatter.get_style_defs(".highlight")
81
+ full_html = (
82
+ "<!DOCTYPE html><html><head><meta charset='UTF-8'><style>"
83
+ f"{style_defs}"
84
+ # Line numbers are in a separate column — suppress selection so
85
+ # Ctrl-A copies only the paste content, not the line numbers.
86
+ " td.linenos { user-select: none; -webkit-user-select: none; }"
87
+ " body { margin: 1em; }"
88
+ " #copy-btn { margin-bottom: 0.5em; cursor: pointer; }"
89
+ "</style></head><body>"
90
+ "<button id='copy-btn' onclick=\""
91
+ "var b=document.getElementById('copy-btn');"
92
+ "navigator.clipboard.writeText(document.querySelector('td.code pre').textContent)"
93
+ ".then(function(){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy';},2000);});"
94
+ "\">Copy</button><br>"
95
+ f"{code_html}"
96
+ "</body></html>"
97
+ )
98
+ return Response(content=full_html, media_type="text/html; charset=UTF-8", headers=headers)
99
+
100
+
101
+ _LANG_OPTIONS = [
102
+ ("", "auto-detect"),
103
+ ("bash", "Bash"),
104
+ ("c", "C"),
105
+ ("cpp", "C++"),
106
+ ("css", "CSS"),
107
+ ("diff", "Diff"),
108
+ ("docker", "Dockerfile"),
109
+ ("go", "Go"),
110
+ ("html", "HTML"),
111
+ ("java", "Java"),
112
+ ("javascript", "JavaScript"),
113
+ ("json", "JSON"),
114
+ ("kotlin", "Kotlin"),
115
+ ("lua", "Lua"),
116
+ ("make", "Makefile"),
117
+ ("markdown", "Markdown"),
118
+ ("nginx", "Nginx"),
119
+ ("php", "PHP"),
120
+ ("python", "Python"),
121
+ ("ruby", "Ruby"),
122
+ ("rust", "Rust"),
123
+ ("scala", "Scala"),
124
+ ("sql", "SQL"),
125
+ ("swift", "Swift"),
126
+ ("toml", "TOML"),
127
+ ("typescript", "TypeScript"),
128
+ ("xml", "XML"),
129
+ ("yaml", "YAML"),
130
+ ]
131
+
132
+
133
+ def _help_page(key: str | None = None) -> str:
134
+ form_html = ""
135
+ if key:
136
+ lang_opts = "\n".join(
137
+ f' <option value="{v}">{_html.escape(label)}</option>'
138
+ for v, label in _LANG_OPTIONS
139
+ )
140
+ form_html = f"""
141
+ <form action="/" method="POST" enctype="multipart/form-data">
142
+ <input type="hidden" name="pste_key" value="{_html.escape(key)}">
143
+ <input type="hidden" name="auto_detect" value="1">
144
+ <textarea name="pste" cols="80" rows="24"></textarea><br>
145
+ <label><input type="checkbox" name="single_view" value="1"> single-view</label>
146
+ &nbsp; expires: <input type="number" name="expires_in_n" min="1" style="width:4em">
147
+ <select name="expires_in_unit">
148
+ <option value="">never</option>
149
+ <option value="H">hours</option>
150
+ <option value="D">days</option>
151
+ <option value="W">weeks</option>
152
+ <option value="M">minutes</option>
153
+ </select>
154
+ &nbsp; lang: <select name="lang">
155
+ {lang_opts}
156
+ </select>
157
+ &nbsp; <button type="submit">paste</button>
158
+ </form>"""
159
+
160
+ return f"""<html><body><style>a{{text-decoration:none}}</style><pre>
161
+ pste(1) PSTE pste(1)
162
+
163
+ NAME
164
+ pste: self-hosted command line paste server (sprunge-inspired).
165
+
166
+ SYNOPSIS
167
+ &lt;command&gt; | curl -F 'pste=&lt;-' -H 'Authorization: Bearer KEY' {BASE_URL}
168
+ pste &lt; file.txt
169
+ echo hello | pste
170
+
171
+ DESCRIPTION
172
+ GET {BASE_URL}/&lt;id&gt; fetch paste as plain text
173
+ GET {BASE_URL}/&lt;id&gt;?&lt;lang&gt; fetch with syntax highlighting
174
+ POST {BASE_URL}/ create paste (auth required)
175
+
176
+ EXAMPLES
177
+ ~$ echo hello | pste
178
+ {BASE_URL}/AB1234
179
+ ~$ pste -l go &lt; main.go
180
+ {BASE_URL}/AB1235?go
181
+ ~$ pste -l &lt; data.json # auto-detect language
182
+ {BASE_URL}/AB1236?json
183
+ ~$ pste AB1234
184
+ hello
185
+ ~$ echo hello | pste -s
186
+ {BASE_URL}/AB1237 # only viewable once
187
+ ~$ echo hello | pste -e 7d
188
+ {BASE_URL}/AB1238 # expires after 7 days
189
+ # expiry format: &lt;n&gt;H hours · &lt;n&gt;D days · &lt;n&gt;W weeks · &lt;n&gt;M minutes
190
+ # e.g. 24H, 7D, 2W, 30M
191
+
192
+ SEE ALSO
193
+ https://github.com/crognlie/pste
194
+ </pre>
195
+ {form_html}
196
+ </body></html>"""
197
+
198
+
199
+ def _result_page(primary_url: str, secondary_url: str | None = None, secondary_label: str | None = None) -> str:
200
+ secondary = ""
201
+ if secondary_url and secondary_label:
202
+ secondary = f'\n<a href="{_html.escape(secondary_url)}">view highlighted ({_html.escape(secondary_label)})</a>'
203
+ return (
204
+ f"<html><body><style>a{{text-decoration:none}}</style><pre>"
205
+ f'<a href="{_html.escape(primary_url)}">{_html.escape(primary_url)}</a>'
206
+ f"{secondary}"
207
+ f"</pre></body></html>"
208
+ )
209
+
210
+
211
+ @app.get("/", response_class=HTMLResponse)
212
+ async def index(key: str | None = None):
213
+ return _help_page(key=key)
214
+
215
+
216
+ @app.post("/")
217
+ async def create_paste(
218
+ request: Request,
219
+ authorization: str | None = Header(default=None),
220
+ db: Session = Depends(get_session),
221
+ ):
222
+ form = await request.form()
223
+
224
+ # Reject unknown fields. Web form sends pste_key as a proxy for auth.
225
+ allowed = ALLOWED_POST_FIELDS | {"pste_key"}
226
+ extra = set(form.keys()) - allowed
227
+ if extra:
228
+ raise HTTPException(status_code=422, detail=f"Unknown fields: {sorted(extra)}")
229
+
230
+ # Web form uses hidden pste_key field instead of Authorization header
231
+ web_key = form.get("pste_key")
232
+ is_web_form = False
233
+ if web_key and not authorization:
234
+ authorization = f"Bearer {web_key}"
235
+ is_web_form = True
236
+
237
+ key_obj = _auth_key(authorization, db)
238
+
239
+ raw = form.get("pste")
240
+ if not raw:
241
+ raise HTTPException(status_code=422, detail="Field 'pste' is required")
242
+
243
+ try:
244
+ content = validate_content(str(raw))
245
+ except ValueError as e:
246
+ raise HTTPException(status_code=422, detail=str(e))
247
+
248
+ auto_detect_requested = str(form.get("auto_detect", "")).lower() in ("1", "true")
249
+
250
+ lang = None
251
+ explicit_lang = False
252
+ raw_lang = form.get("lang")
253
+ if raw_lang:
254
+ try:
255
+ lang = validate_lang(str(raw_lang))
256
+ explicit_lang = True
257
+ except ValueError as e:
258
+ raise HTTPException(status_code=422, detail=str(e))
259
+
260
+ expires_at = None
261
+ raw_expires = form.get("expires_at")
262
+ expires_in_n = str(form.get("expires_in_n", "")).strip()
263
+ expires_in_unit = str(form.get("expires_in_unit", "")).strip()
264
+ if raw_expires:
265
+ try:
266
+ expires_at = validate_expires_at(str(raw_expires))
267
+ except ValueError as e:
268
+ raise HTTPException(status_code=422, detail=str(e))
269
+ elif expires_in_n and expires_in_unit:
270
+ try:
271
+ expires_at = validate_expires_in(expires_in_n, expires_in_unit)
272
+ except ValueError as e:
273
+ raise HTTPException(status_code=422, detail=str(e))
274
+
275
+ single_view_raw = form.get("single_view", "")
276
+ single_view = str(single_view_raw).lower() in ("1", "true")
277
+
278
+ # Auto-detect language only when explicitly requested (web form or -l flag with no value)
279
+ auto_lang = None
280
+ if not lang and auto_detect_requested:
281
+ try:
282
+ guessed = guess_lexer(content)
283
+ score = type(guessed).analyse_text(content)
284
+ if score > 0.5 and guessed.aliases:
285
+ auto_lang = guessed.aliases[0]
286
+ lang = auto_lang
287
+ except Exception:
288
+ pass
289
+
290
+ storage = get_storage()
291
+ id_length = get_id_length(db)
292
+
293
+ for _ in range(10):
294
+ paste_id = generate_id(id_length)
295
+ if db.query(Paste).filter(Paste.id == paste_id).first():
296
+ continue
297
+
298
+ gcs_key = storage.store(paste_id, content)
299
+
300
+ paste = Paste(
301
+ id=paste_id,
302
+ created_by=key_obj.key,
303
+ expires_at=expires_at,
304
+ single_view=single_view,
305
+ lang=lang,
306
+ size_bytes=len(content.encode("utf-8")),
307
+ content=content if gcs_key is None else None,
308
+ gcs_key=gcs_key,
309
+ )
310
+ db.add(paste)
311
+ try:
312
+ db.commit()
313
+ if expires_at is not None:
314
+ schedule_paste_expiry(paste_id, expires_at)
315
+ break
316
+ except IntegrityError:
317
+ db.rollback()
318
+ if gcs_key is not None:
319
+ try:
320
+ storage.delete(paste_id, gcs_key)
321
+ except Exception:
322
+ pass
323
+ else:
324
+ raise HTTPException(status_code=500, detail="Could not generate unique ID")
325
+
326
+ plain_url = f"{BASE_URL}/{paste_id}"
327
+ highlighted_url = f"{plain_url}?{lang}" if lang else None
328
+
329
+ if is_web_form:
330
+ if explicit_lang:
331
+ # User picked a language — show the highlighted URL directly
332
+ return HTMLResponse(_result_page(highlighted_url))
333
+ elif auto_lang:
334
+ # Auto-detected — show plain URL and offer highlighted as option
335
+ return HTMLResponse(_result_page(plain_url, highlighted_url, auto_lang))
336
+ else:
337
+ # No lang — plain URL only
338
+ return HTMLResponse(_result_page(plain_url))
339
+
340
+ # API/CLI: return URL with ?lang when lang is known (explicit or detected), plain otherwise
341
+ return PlainTextResponse((highlighted_url or plain_url) + "\n")
342
+
343
+
344
+ @app.get("/{paste_id}")
345
+ async def get_paste(paste_id: str, request: Request, db: Session = Depends(get_session)):
346
+ paste_id = paste_id.upper()
347
+
348
+ query_string = request.url.query
349
+
350
+ paste = db.query(Paste).filter(Paste.id == paste_id, Paste.deleted_at.is_(None)).first()
351
+ if not paste:
352
+ raise HTTPException(status_code=404, detail=f"{paste_id} not found.")
353
+
354
+ created_header = {"X-Pste-Created": paste.created_at.strftime("%Y-%m-%dT%H:%M:%SZ")}
355
+
356
+ gcs_key = paste.gcs_key
357
+ content_inline = paste.content
358
+ lang = paste.lang
359
+
360
+ if paste.single_view:
361
+ if hard_delete_on_single_view():
362
+ # Atomic DB delete — whoever wins the race (rowcount==1) serves the content
363
+ result = db.execute(
364
+ sa_delete(Paste).where(Paste.id == paste_id, Paste.deleted_at.is_(None))
365
+ )
366
+ db.commit()
367
+ if result.rowcount == 0:
368
+ raise HTTPException(status_code=404, detail=f"{paste_id} not found.")
369
+ storage = get_storage()
370
+ content = storage.retrieve(paste_id, gcs_key, content_inline)
371
+ storage.delete(paste_id, gcs_key)
372
+ else:
373
+ # Atomic soft-delete claim — race loser gets rowcount==0 → 404
374
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
375
+ result = db.execute(
376
+ sa_update(Paste)
377
+ .where(Paste.id == paste_id, Paste.deleted_at.is_(None))
378
+ .values(deleted_at=now, deleted_reason="single_view")
379
+ )
380
+ db.commit()
381
+ if result.rowcount == 0:
382
+ raise HTTPException(status_code=404, detail=f"{paste_id} not found.")
383
+ storage = get_storage()
384
+ content = storage.retrieve(paste_id, gcs_key, content_inline)
385
+ else:
386
+ storage = get_storage()
387
+ content = storage.retrieve(paste_id, gcs_key, content_inline)
388
+
389
+ if query_string and query_string.lower() != "none":
390
+ return _render_highlighted(content, query_string, headers=created_header)
391
+
392
+ return PlainTextResponse(content + "\n", headers=created_header)
393
+
394
+
395
+ def run():
396
+ import uvicorn
397
+ port = int(os.environ.get("PORT", 8000))
398
+ uvicorn.run("pste_server.main:app", host="0.0.0.0", port=port, reload=False)
pste_server/models.py ADDED
@@ -0,0 +1,75 @@
1
+ import os
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import (
5
+ Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine
6
+ )
7
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
8
+
9
+ _engine = None
10
+ _SessionLocal = None
11
+
12
+
13
+ class Base(DeclarativeBase):
14
+ pass
15
+
16
+
17
+ class ServerState(Base):
18
+ __tablename__ = "server_state"
19
+
20
+ key = Column(String, primary_key=True)
21
+ value = Column(String, nullable=False)
22
+
23
+
24
+ class Key(Base):
25
+ __tablename__ = "keys"
26
+
27
+ key = Column(String, primary_key=True)
28
+ user = Column(String)
29
+ notes = Column(Text)
30
+ disabled = Column(Boolean, default=False, nullable=False)
31
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
32
+
33
+
34
+ class Paste(Base):
35
+ __tablename__ = "pastes"
36
+
37
+ id = Column(String, primary_key=True)
38
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
39
+ created_by = Column(String, ForeignKey("keys.key"))
40
+ expires_at = Column(DateTime)
41
+ single_view = Column(Boolean, default=False, nullable=False)
42
+ deleted_at = Column(DateTime)
43
+ deleted_reason = Column(String)
44
+ lang = Column(String)
45
+ size_bytes = Column(Integer)
46
+ content = Column(Text)
47
+ gcs_key = Column(String)
48
+
49
+
50
+ def get_engine():
51
+ global _engine
52
+ if _engine is None:
53
+ backend = os.environ.get("STORAGE_BACKEND", "sqlite")
54
+ if backend == "sqlite":
55
+ path = os.environ.get("SQLITE_PATH", "./data/pste.db")
56
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
57
+ _engine = create_engine(f"sqlite:///{path}", connect_args={"check_same_thread": False})
58
+ elif backend in ("postgresql", "gcs"):
59
+ url = os.environ["DATABASE_URL"]
60
+ _engine = create_engine(url, pool_pre_ping=True)
61
+ else:
62
+ raise ValueError(f"Unknown STORAGE_BACKEND: {backend}")
63
+ Base.metadata.create_all(_engine)
64
+ return _engine
65
+
66
+
67
+ def get_session():
68
+ global _SessionLocal
69
+ if _SessionLocal is None:
70
+ _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
71
+ db = _SessionLocal()
72
+ try:
73
+ yield db
74
+ finally:
75
+ db.close()
@@ -0,0 +1,67 @@
1
+ import ipaddress
2
+ import threading
3
+ from collections import defaultdict, deque
4
+ from datetime import datetime, timezone
5
+
6
+ RATE_LIMIT = 10
7
+ WINDOW_SECONDS = 60
8
+
9
+ _PRIVATE_NETWORKS = [
10
+ ipaddress.ip_network("127.0.0.0/8"),
11
+ ipaddress.ip_network("10.0.0.0/8"),
12
+ ipaddress.ip_network("172.16.0.0/12"),
13
+ ipaddress.ip_network("192.168.0.0/16"),
14
+ ipaddress.ip_network("100.64.0.0/10"), # Tailscale CGNAT
15
+ ipaddress.ip_network("::1/128"),
16
+ ipaddress.ip_network("fc00::/7"), # IPv6 ULA
17
+ ]
18
+
19
+ _lock = threading.Lock()
20
+ _get_buckets: dict[str, deque] = defaultdict(deque)
21
+ _post_buckets: dict[str, deque] = defaultdict(deque)
22
+
23
+
24
+ def _is_private(ip_str: str) -> bool:
25
+ try:
26
+ addr = ipaddress.ip_address(ip_str)
27
+ return any(addr in net for net in _PRIVATE_NETWORKS)
28
+ except ValueError:
29
+ return False
30
+
31
+
32
+ def _client_ip(request) -> str:
33
+ direct = request.client.host if request.client else None
34
+ # Only trust proxy headers when the direct connection is from a known private proxy
35
+ if direct and _is_private(direct):
36
+ if xff := request.headers.get("x-forwarded-for"):
37
+ return xff.split(",")[0].strip()
38
+ if xri := request.headers.get("x-real-ip"):
39
+ return xri.strip()
40
+ return direct or "unknown"
41
+
42
+
43
+ def _check(bucket: deque, now: float) -> bool:
44
+ cutoff = now - WINDOW_SECONDS
45
+ while bucket and bucket[0] < cutoff:
46
+ bucket.popleft()
47
+ if len(bucket) >= RATE_LIMIT:
48
+ return False
49
+ bucket.append(now)
50
+ return True
51
+
52
+
53
+ def is_allowed(request) -> bool:
54
+ ip = _client_ip(request)
55
+ if _is_private(ip):
56
+ return True
57
+ now = datetime.now(timezone.utc).timestamp()
58
+ method = request.method.upper()
59
+ with _lock:
60
+ bucket = _get_buckets[ip] if method in ("GET", "HEAD") else _post_buckets[ip]
61
+ return _check(bucket, now)
62
+
63
+
64
+ def reset():
65
+ with _lock:
66
+ _get_buckets.clear()
67
+ _post_buckets.clear()
pste_server/reaper.py ADDED
@@ -0,0 +1,212 @@
1
+ import logging
2
+ import threading
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ _reaper_timer: threading.Timer | None = None
8
+ _paste_timers: dict[str, threading.Timer] = {}
9
+ _paste_timers_lock = threading.Lock()
10
+
11
+ # Schedule a per-paste timer at creation time for pastes expiring sooner than
12
+ # this window, so they aren't delayed up to 30 min waiting for the next scan.
13
+ _EARLY_SCHEDULE_WINDOW = timedelta(minutes=45)
14
+
15
+
16
+ def _expire_paste(paste_id: str):
17
+ from pste_server.config import hard_delete_on_expire
18
+ from pste_server.models import Paste, get_engine
19
+ from sqlalchemy.orm import sessionmaker
20
+
21
+ Session = sessionmaker(bind=get_engine())
22
+ db = Session()
23
+ try:
24
+ paste = db.query(Paste).filter(
25
+ Paste.id == paste_id,
26
+ Paste.deleted_at.is_(None),
27
+ ).first()
28
+ if paste:
29
+ if hard_delete_on_expire():
30
+ from pste_server.storage import get_storage
31
+ gcs_key = paste.gcs_key
32
+ db.delete(paste)
33
+ db.commit()
34
+ logger.info("Hard-deleted expired paste %s", paste_id)
35
+ try:
36
+ get_storage().delete(paste_id, gcs_key)
37
+ except Exception:
38
+ logger.exception("GCS delete failed for expired paste %s (DB row already removed)", paste_id)
39
+ return
40
+ else:
41
+ paste.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
42
+ paste.deleted_reason = "expired"
43
+ logger.info("Soft-deleted expired paste %s", paste_id)
44
+ db.commit()
45
+ except Exception:
46
+ logger.exception("Error expiring paste %s", paste_id)
47
+ db.rollback()
48
+ finally:
49
+ db.close()
50
+
51
+
52
+ def _schedule_timer(paste_id: str, delay: float):
53
+ """Create, start, and register a per-paste expiry timer. Caller holds _paste_timers_lock."""
54
+ t = threading.Timer(delay, _expire_paste, args=[paste_id])
55
+ t.daemon = True
56
+ t.start()
57
+ _paste_timers[paste_id] = t
58
+ logger.debug("Scheduled expiry for %s in %.1fs", paste_id, delay)
59
+
60
+
61
+ def schedule_paste_expiry(paste_id: str, expires_at: datetime):
62
+ """Schedule a per-paste expiry timer if the paste expires within the early window.
63
+
64
+ Called at paste creation time to ensure pastes with short expiry times are
65
+ handled promptly, rather than waiting up to 30 minutes for the next scan.
66
+ No-op if the paste expires beyond the window or a timer is already active.
67
+ """
68
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
69
+ time_until = expires_at - now
70
+ if time_until <= timedelta(0) or time_until > _EARLY_SCHEDULE_WINDOW:
71
+ return
72
+ delay = time_until.total_seconds()
73
+ with _paste_timers_lock:
74
+ existing = _paste_timers.get(paste_id)
75
+ if existing and existing.is_alive():
76
+ return
77
+ dead = [pid for pid, t in _paste_timers.items() if not t.is_alive()]
78
+ for pid in dead:
79
+ del _paste_timers[pid]
80
+ _schedule_timer(paste_id, delay)
81
+
82
+
83
+ def _scan_and_schedule():
84
+ from pste_server.models import Paste, get_engine
85
+ from sqlalchemy.orm import sessionmaker
86
+
87
+ Session = sessionmaker(bind=get_engine())
88
+ db = Session()
89
+ try:
90
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
91
+ window_end = now + timedelta(minutes=30)
92
+ upcoming = db.query(Paste).filter(
93
+ Paste.expires_at.isnot(None),
94
+ Paste.expires_at > now,
95
+ Paste.expires_at <= window_end,
96
+ Paste.deleted_at.is_(None),
97
+ ).all()
98
+
99
+ for paste in upcoming:
100
+ delay = (paste.expires_at - now).total_seconds()
101
+ delay = max(0.0, delay)
102
+ with _paste_timers_lock:
103
+ existing = _paste_timers.get(paste.id)
104
+ if existing and existing.is_alive():
105
+ continue
106
+ dead = [pid for pid, t in _paste_timers.items() if not t.is_alive()]
107
+ for pid in dead:
108
+ del _paste_timers[pid]
109
+ _schedule_timer(paste.id, delay)
110
+
111
+ # Also immediately expire anything past due that wasn't caught
112
+ from pste_server.config import hard_delete_on_expire
113
+ from pste_server.storage import get_storage
114
+ overdue = db.query(Paste).filter(
115
+ Paste.expires_at.isnot(None),
116
+ Paste.expires_at <= now,
117
+ Paste.deleted_at.is_(None),
118
+ ).all()
119
+ overdue_gcs = []
120
+ for paste in overdue:
121
+ if hard_delete_on_expire():
122
+ overdue_gcs.append((paste.id, paste.gcs_key))
123
+ db.delete(paste)
124
+ logger.info("Hard-deleted overdue paste %s", paste.id)
125
+ else:
126
+ paste.deleted_at = now
127
+ paste.deleted_reason = "expired"
128
+ logger.info("Soft-deleted overdue paste %s", paste.id)
129
+ if overdue:
130
+ db.commit()
131
+ for pid, gkey in overdue_gcs:
132
+ get_storage().delete(pid, gkey)
133
+
134
+ _purge_deferred(db, now)
135
+
136
+ total = db.query(Paste).count()
137
+ from pste_server.id_gen import bump_id_length_if_needed
138
+ bump_id_length_if_needed(total, db)
139
+ except Exception:
140
+ logger.exception("Error in reaper scan")
141
+ db.rollback()
142
+ finally:
143
+ db.close()
144
+
145
+ _schedule_next()
146
+
147
+
148
+ def _purge_deferred(db, now: datetime):
149
+ """Hard-delete soft-deleted records that have aged past DELETE_AFTER_* thresholds."""
150
+ from pste_server.config import delete_after_expire, delete_after_single_view
151
+
152
+ after_expire = delete_after_expire()
153
+ after_sv = delete_after_single_view()
154
+
155
+ if not after_expire and not after_sv:
156
+ return
157
+
158
+ reasons = []
159
+ if after_expire:
160
+ reasons.append(("expired", after_expire))
161
+ if after_sv:
162
+ reasons.append(("single_view", after_sv))
163
+
164
+ from pste_server.models import Paste
165
+ from pste_server.storage import get_storage
166
+
167
+ total = 0
168
+ all_gcs = []
169
+ storage = get_storage()
170
+ for reason, delta in reasons:
171
+ cutoff = now - delta
172
+ stale = db.query(Paste).filter(
173
+ Paste.deleted_reason == reason,
174
+ Paste.deleted_at.isnot(None),
175
+ Paste.deleted_at <= cutoff,
176
+ ).all()
177
+ for paste in stale:
178
+ all_gcs.append((paste.id, paste.gcs_key))
179
+ db.delete(paste)
180
+ logger.info("Purged %s paste %s (deleted_at=%s, cutoff=%s)", reason, paste.id, paste.deleted_at, cutoff)
181
+ total += len(stale)
182
+
183
+ if total:
184
+ db.commit()
185
+ for pid, gkey in all_gcs:
186
+ storage.delete(pid, gkey)
187
+
188
+
189
+ def _schedule_next():
190
+ global _reaper_timer
191
+ _reaper_timer = threading.Timer(30 * 60, _scan_and_schedule)
192
+ _reaper_timer.daemon = True
193
+ _reaper_timer.start()
194
+
195
+
196
+ def start_reaper():
197
+ t = threading.Thread(target=_scan_and_schedule, daemon=True)
198
+ t.start()
199
+
200
+
201
+ def stop_reaper():
202
+ global _reaper_timer
203
+ if _reaper_timer:
204
+ _reaper_timer.cancel()
205
+ _reaper_timer = None
206
+ with _paste_timers_lock:
207
+ for t in _paste_timers.values():
208
+ t.cancel()
209
+ _paste_timers.clear()
210
+ # Note: a timer registered between the last append and this lock acquisition
211
+ # won't be cancelled here, but all timers are daemon threads so process
212
+ # exit is clean regardless.
pste_server/storage.py ADDED
@@ -0,0 +1,71 @@
1
+ import abc
2
+ import os
3
+ import threading
4
+
5
+
6
+ class StorageBackend(abc.ABC):
7
+ @abc.abstractmethod
8
+ def store(self, paste_id: str, content: str) -> str | None:
9
+ """Store content; return gcs_key if using GCS, else None."""
10
+
11
+ @abc.abstractmethod
12
+ def retrieve(self, paste_id: str, gcs_key: str | None, db_content: str | None) -> str:
13
+ """Return paste content as a string."""
14
+
15
+ def delete(self, paste_id: str, gcs_key: str | None) -> None:
16
+ """Delete stored content. No-op for SQL backends."""
17
+
18
+
19
+ class SqlStorage(StorageBackend):
20
+ def store(self, paste_id: str, content: str) -> None:
21
+ return None
22
+
23
+ def retrieve(self, paste_id: str, gcs_key: str | None, db_content: str | None) -> str:
24
+ return db_content or ""
25
+
26
+
27
+ class GcsStorage(StorageBackend):
28
+ def __init__(self):
29
+ try:
30
+ from google.cloud import storage as gcs
31
+ except ImportError:
32
+ raise ImportError(
33
+ "GCS storage requires the 'gcs' extra: pip install pste-server[gcs]"
34
+ )
35
+ self._bucket_name = os.environ["GCS_BUCKET"]
36
+ self._client = gcs.Client()
37
+ self._bucket = self._client.bucket(self._bucket_name)
38
+
39
+ def store(self, paste_id: str, content: str) -> str:
40
+ blob = self._bucket.blob(paste_id)
41
+ blob.upload_from_string(content.encode("utf-8"), content_type="text/plain; charset=UTF-8")
42
+ return paste_id
43
+
44
+ def retrieve(self, paste_id: str, gcs_key: str | None, db_content: str | None) -> str:
45
+ key = gcs_key or paste_id
46
+ blob = self._bucket.blob(key)
47
+ return blob.download_as_text(encoding="utf-8")
48
+
49
+ def delete(self, paste_id: str, gcs_key: str | None) -> None:
50
+ key = gcs_key or paste_id
51
+ self._bucket.blob(key).delete()
52
+
53
+
54
+ _backend: StorageBackend | None = None
55
+ _backend_lock = threading.Lock()
56
+
57
+
58
+ def get_storage() -> StorageBackend:
59
+ global _backend
60
+ if _backend is not None:
61
+ return _backend
62
+ with _backend_lock:
63
+ if _backend is None:
64
+ backend_name = os.environ.get("STORAGE_BACKEND", "sqlite")
65
+ if backend_name in ("sqlite", "postgresql"):
66
+ _backend = SqlStorage()
67
+ elif backend_name == "gcs":
68
+ _backend = GcsStorage()
69
+ else:
70
+ raise ValueError(f"Unknown STORAGE_BACKEND: {backend_name}")
71
+ return _backend
@@ -0,0 +1,67 @@
1
+ import os
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from pygments.lexers import get_lexer_by_name
5
+ from pygments.util import ClassNotFound
6
+
7
+ MAX_PASTE_BYTES = int(os.environ.get("MAX_PASTE_BYTES", 1048576))
8
+
9
+ ALLOWED_POST_FIELDS = {"pste", "lang", "auto_detect", "expires_at", "expires_in_n", "expires_in_unit", "single_view"}
10
+
11
+ _UNIT_SECONDS = {"H": 3600, "D": 86400, "W": 7 * 86400, "M": 60}
12
+
13
+
14
+ def validate_content(content: str) -> str:
15
+ """Raises ValueError if content is invalid; returns content."""
16
+ try:
17
+ encoded = content.encode("utf-8")
18
+ except UnicodeEncodeError as e:
19
+ raise ValueError(f"Content must be valid UTF-8: {e}")
20
+ if len(encoded) > MAX_PASTE_BYTES:
21
+ raise ValueError(
22
+ f"Paste exceeds maximum size of {MAX_PASTE_BYTES} bytes "
23
+ f"(got {len(encoded)} bytes)"
24
+ )
25
+ return content
26
+
27
+
28
+ def validate_lang(lang: str) -> str:
29
+ """Raises ValueError if lang is not a known Pygments lexer name."""
30
+ if lang.lower() == "none":
31
+ raise ValueError("'none' is reserved; omit lang to store no default")
32
+ try:
33
+ get_lexer_by_name(lang)
34
+ except ClassNotFound:
35
+ raise ValueError(f"Unknown language/lexer: {lang!r}")
36
+ return lang
37
+
38
+
39
+ def validate_expires_in(n_str: str, unit: str) -> datetime:
40
+ """Parse expires_in_n + expires_in_unit (web form); raises ValueError if invalid."""
41
+ try:
42
+ n = int(n_str)
43
+ if n <= 0:
44
+ raise ValueError()
45
+ except (ValueError, TypeError):
46
+ raise ValueError(f"expiry amount must be a positive integer, got {n_str!r}")
47
+ unit = unit.upper()
48
+ if unit not in _UNIT_SECONDS:
49
+ raise ValueError(f"expiry unit must be one of H, D, W, M; got {unit!r}")
50
+ try:
51
+ return datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=n * _UNIT_SECONDS[unit])
52
+ except OverflowError:
53
+ raise ValueError(f"expiry duration is too large")
54
+
55
+
56
+ def validate_expires_at(value: str) -> datetime:
57
+ """Parse ISO8601 UTC timestamp; raises ValueError if invalid or in the past."""
58
+ try:
59
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
60
+ except ValueError:
61
+ raise ValueError(f"Invalid expires_at format (expected ISO8601): {value!r}")
62
+ if dt.tzinfo is None:
63
+ raise ValueError("expires_at must include timezone (use UTC, e.g. 2026-01-01T00:00:00Z)")
64
+ dt_utc = dt.astimezone(timezone.utc).replace(tzinfo=None)
65
+ if dt_utc <= datetime.now(timezone.utc).replace(tzinfo=None):
66
+ raise ValueError("expires_at must be in the future")
67
+ return dt_utc
@@ -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,14 @@
1
+ pste_server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pste_server/admin.py,sha256=U047Mg6MX-o0OoWmjQp--OYws0XwLZ5IJLt4oyiP1cw,6558
3
+ pste_server/config.py,sha256=AIiWgL-_UICNB6Xkp_xeLYYD5F8oPJL-mXJwkQQZ4dQ,1342
4
+ pste_server/id_gen.py,sha256=nqiljDpCjf6NZVONIXBZ_Q1vIx7NZb5J3dLFYLwOp7g,1628
5
+ pste_server/main.py,sha256=EAQlfrh0aQeJhO61YkV4KxRS4iVDEBRxhPeU1ugCKNY,13995
6
+ pste_server/models.py,sha256=ve3uViEYzGT9B_mIT_djt34mVhUBKZIL7H-NQV4SoMo,2182
7
+ pste_server/ratelimit.py,sha256=Z5_nysRQKIOBkbqOcS077XBhgcUFHrD1nGm9YTyyxts,1933
8
+ pste_server/reaper.py,sha256=l612rG1QV3_1QigC_5gFQrjrCN8XgI62R87mensu5jo,7353
9
+ pste_server/storage.py,sha256=mL_2d7eSt7zCxLSt5rC0CSXaW5ocRLGl-5858YFMg5A,2378
10
+ pste_server/validation.py,sha256=eUV3rAQXJ7a0e1s_9JpIfCw8htWCg44absVNkUGmLkg,2576
11
+ pste_server-0.1.0.dist-info/METADATA,sha256=hqpoPpNhhv3MJUHaqohJesfVStQhiLdYZ1pb4L8PHiI,6306
12
+ pste_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ pste_server-0.1.0.dist-info/entry_points.txt,sha256=sVb9-70pEEY6cmP4FgAPnPpTTGvObjxE8CtObHUh3K4,88
14
+ pste_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pste-admin = pste_server.admin:cli
3
+ pste-server = pste_server.main:run