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.
- pste_server/__init__.py +0 -0
- pste_server/admin.py +209 -0
- pste_server/config.py +41 -0
- pste_server/id_gen.py +50 -0
- pste_server/main.py +398 -0
- pste_server/models.py +75 -0
- pste_server/ratelimit.py +67 -0
- pste_server/reaper.py +212 -0
- pste_server/storage.py +71 -0
- pste_server/validation.py +67 -0
- pste_server-0.1.0.dist-info/METADATA +147 -0
- pste_server-0.1.0.dist-info/RECORD +14 -0
- pste_server-0.1.0.dist-info/WHEEL +4 -0
- pste_server-0.1.0.dist-info/entry_points.txt +3 -0
pste_server/__init__.py
ADDED
|
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
|
+
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
|
+
lang: <select name="lang">
|
|
155
|
+
{lang_opts}
|
|
156
|
+
</select>
|
|
157
|
+
<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
|
+
<command> | curl -F 'pste=<-' -H 'Authorization: Bearer KEY' {BASE_URL}
|
|
168
|
+
pste < file.txt
|
|
169
|
+
echo hello | pste
|
|
170
|
+
|
|
171
|
+
DESCRIPTION
|
|
172
|
+
GET {BASE_URL}/<id> fetch paste as plain text
|
|
173
|
+
GET {BASE_URL}/<id>?<lang> 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 < main.go
|
|
180
|
+
{BASE_URL}/AB1235?go
|
|
181
|
+
~$ pste -l < 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: <n>H hours · <n>D days · <n>W weeks · <n>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()
|
pste_server/ratelimit.py
ADDED
|
@@ -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,,
|