Skip to content

Commit 682af3c

Browse files
committed
Project modernised:
- Removed /view legacy URLs - Ruff formatting - Added typing - UV build and running via uvx - Static files now under pastezi
1 parent 0298f51 commit 682af3c

630 files changed

Lines changed: 226 additions & 172 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
.vscode
2-
.env
3-
env
1+
.*
2+
!.gitignore
3+
*.lock
44
.DS_Store
55
__pycache__
6-
Pipfile.lock

MANIFEST.in

Lines changed: 0 additions & 1 deletion
This file was deleted.

Pipfile

Lines changed: 0 additions & 16 deletions
This file was deleted.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@ A PUT request to site root creates a randomly named paste, while a PUT request t
2323
wget https://paste.zi.fi/p/your_file.txt
2424

2525
Notice that binary files are not supported and that trailing newlines and such may get altered.
26+
27+
## Run the server
28+
29+
It is recommended to use [uv](https://docs.astral.sh/uv/getting-started/installation/) and [Caddy](https://caddyserver.com/) for deployment beyond localhost.
30+
31+
```sh
32+
uvx --with git+https://github.com/Tronic/pastezi.git sanic pastezi:app
33+
```
34+
35+
Set env `SANIC_SERVER_NAME=https://example.com` with an external domain and https if needed.

pastezi/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
from .app import app
2+
3+
__all__: list[str] = ["app"]

pastezi/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import sys
22

3-
print("""\
3+
4+
def main() -> None:
5+
print("""\
46
Please run with Sanic CLI, not as a module!
57
68
# Using Sanic stand-alone server (with your certificates)
@@ -9,4 +11,8 @@
911
# Locally/proxied (set SANIC_SERVER_NAME to public URL with no trailing slash)
1012
SANIC_SERVER_NAME="https://paste.zi.fi" sanic --port 8000 pastezi:app
1113
""")
12-
sys.exit(1)
14+
sys.exit(1)
15+
16+
17+
if __name__ == "__main__":
18+
main()

pastezi/app.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,105 @@
11
import mimetypes
22
from pathlib import Path
3+
from typing import TYPE_CHECKING
34

4-
from sanic import Sanic
5+
from sanic import Request, Sanic
56
from sanic.response import empty, html, redirect, text
67

78
from . import content, db
89
from .layout import Layout
910

11+
if TYPE_CHECKING:
12+
backend: db.Backend
1013

11-
app = Sanic("pastezi", strict_slashes=True)
14+
app: Sanic = Sanic("pastezi", strict_slashes=True)
1215

1316
app.config.REQUEST_MAX_SIZE = 1_000_000
1417

15-
staticdir = Path(__file__).resolve().parent.parent / "static"
18+
staticdir = Path(__file__).resolve().parent / "static"
1619
if not staticdir.is_dir():
1720
raise RuntimeError(f"Static files not found in {staticdir}")
1821
app.static("/", staticdir)
1922

23+
2024
@app.before_server_start
21-
async def init(app):
25+
async def init(app: Sanic) -> None:
2226
global backend
2327
backend = db.Backend()
2428
await backend.start()
2529

30+
2631
@app.get("/", name="index")
27-
@app.get(f"/p/<paste_id>/edit")
28-
async def edit_paste(req, paste_id=None):
32+
@app.get("/p/<paste_id>/edit")
33+
async def edit_paste(req: Request, paste_id: str | None = None):
2934
paste = paste_id and await backend[paste_id]
3035
layout = Layout(req)
3136
return html(layout.edit_paste(paste, paste_id))
3237

38+
3339
# Forced raw download - end with filename for wget support
34-
@app.get(f"/dl/<paste_id>")
35-
async def download_paste(req, paste_id):
40+
@app.get("/dl/<paste_id>")
41+
async def download_paste(req: Request, paste_id: str):
3642
paste = await backend[paste_id]
37-
if not paste: return text(None, status=404)
38-
headers = {"content-type": mimetypes.guess_type(paste_id), "content-disposition": "attachment"}
43+
if not paste:
44+
return empty(status=404)
45+
headers = {
46+
"content-type": mimetypes.guess_type(paste_id),
47+
"content-disposition": "attachment",
48+
}
3949
return text(paste["text"], headers=headers)
4050

51+
4152
# View for browser and API depending on accept header
42-
@app.get(f"/p/<paste_id>/view", name="old_view_paste")
43-
@app.get(f"/p/<paste_id>")
44-
async def view_paste(req, paste_id):
53+
@app.get("/p/<paste_id>")
54+
async def view_paste(req: Request, paste_id: str):
4555
paste = await backend[paste_id]
4656
layout = Layout(req)
47-
h = "text/html" in req.headers.get("accept", "") # Note: "text/html" in req.accept allows */* too!
57+
h = "text/html" in req.headers.accept
4858
if not paste:
4959
return html(layout.view_paste(None, paste_id), status=404) if h else empty(404)
5060
return html(layout.view_paste(paste, paste_id)) if h else text(paste["text"])
5161

62+
5263
# HTML form API
5364
@app.post("/")
54-
async def post_paste(req):
65+
async def post_paste(req: Request):
5566
await req.receive_body()
56-
paste, paste_id = req.form.get("paste"), req.form.get("paste_id")
57-
if not paste and "paste" in req.files:
67+
if not req.form or not req.files:
68+
raise ValueError("No form data received")
69+
70+
paste = req.form.get("paste")
71+
paste_id = req.form.get("paste_id")
72+
if not paste and req.files and "paste" in req.files:
5873
mime, paste, paste_id = req.files["paste"][0]
59-
paste_id, paste_object = await content.process_paste(paste, paste_id, fallback_charset=req.args.get("charset"))
74+
paste_id, paste_object = await content.process_paste(
75+
paste, paste_id, fallback_charset=req.args.get("charset")
76+
)
6077
created = await backend.store(paste_id, paste_object)
61-
url = get_url(req, paste_id)
62-
if "text/html" in req.headers.getone("accept", "*/*"):
78+
url = req.url_for("view_paste", paste_id=paste_id)
79+
if "text/html" in req.headers.accept:
6380
return redirect(url)
6481
else:
6582
status = 201 if created else 200
6683
return text(f"{url}\n", status=status)
6784

85+
6886
# CRUD API
6987

88+
7089
@app.put("/", name="put_paste_noid")
7190
@app.put("/p/", name="put_paste_noid2")
7291
@app.put("/p/<paste_id>")
7392
async def put_paste(req, paste_id=None):
7493
await req.receive_body()
75-
paste_id, paste_object = await content.process_paste(req.body, paste_id, fallback_charset=req.args.get("charset"))
94+
paste_id, paste_object = await content.process_paste(
95+
req.body, paste_id, fallback_charset=req.args.get("charset")
96+
)
7697
created = await backend.store(paste_id, paste_object)
7798
status = 201 if created else 200
7899
url = req.url_for("view_paste", paste_id=paste_id)
79100
return text(f"{url}\n", status=status)
80101

102+
81103
@app.delete("/p/<paste_id>")
82104
async def delete_paste(req, paste_id):
83105
deleted = await backend.delete(paste_id)

pastezi/content.py

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import re
2+
13
import pygments
2-
from pygments.lexers import get_lexer_for_filename, guess_lexer
3-
from pygments.formatters import HtmlFormatter # pylint: disable=no-name-in-module
4-
import re, os
5-
from .helper import make_async
6-
from sanic.exceptions import NotFound, InvalidUsage
74
from pronounceable import PronounceableWord
5+
from pygments.formatters import HtmlFormatter # type: ignore
6+
from pygments.lexers import get_lexer_for_filename
7+
from sanic.exceptions import InvalidUsage
88

9-
matchers = (
9+
from .helper import make_async
10+
11+
matchers: list[tuple[str, re.Pattern[str]]] = [
1012
# First try shebangs and other things at the beginning of the file
1113
(".sh", re.compile(r"^#!/bin/(ba)?sh\s")),
1214
(".scala", re.compile(r"^#!/.*scala\s")),
@@ -25,10 +27,23 @@
2527
(".java", re.compile(r"^public class \w+", re.MULTILINE)),
2628
(".cpp", re.compile(r"#include .*\w::\w|using namespace \w+;", re.DOTALL)),
2729
(".c", re.compile(r"#include .*(malloc|printf)\(|int main\(void\)", re.DOTALL)),
28-
(".js", re.compile(r"^\s*console.log\(|^\s*(var|let|const) \w+ = require|\) => {$|^\s*function( \w+)?\(", re.MULTILINE)),
30+
(
31+
".js",
32+
re.compile(
33+
r"^\s*console.log\(|^\s*(var|let|const) \w+ = require|\) => {$|^\s*function( \w+)?\(",
34+
re.MULTILINE,
35+
),
36+
),
2937
(".php", re.compile(r"<\?php.*\?>")),
30-
(".css", re.compile(r"^\s*(color: *#[0-9a-fA-F]{3,6}|width: \d+(px|r?em|%));$", re.IGNORECASE | re.MULTILINE)),
31-
)
38+
(
39+
".css",
40+
re.compile(
41+
r"^\s*(color: *#[0-9a-fA-F]{3,6}|width: \d+(px|r?em|%));$",
42+
re.IGNORECASE | re.MULTILINE,
43+
),
44+
),
45+
]
46+
3247

3348
class Formatter(HtmlFormatter):
3449
# Link & anchor line numbers
@@ -37,55 +52,82 @@ def _wrap_lineanchors(self, inner):
3752
for t, line in inner:
3853
if t:
3954
i += 1
40-
yield 1, f'<a class=line href=#{i} id={i} tabindex=-1></a>' + line
55+
yield 1, f"<a class=line href=#{i} id={i} tabindex=-1></a>" + line
4156
else:
4257
yield 0, line
58+
4359
# A bit shorter wrapper, and link URLs
4460
def _wrap_pre(self, inner):
45-
yield 0, '<pre><code>'
61+
yield 0, "<pre><code>"
4662
for i, t in inner:
4763
if i == 1:
48-
t = re.sub(r'(http[s]?://\S+)', r'<a href="\1">\1</a>', t)
64+
t = re.sub(r"(http[s]?://\S+)", r'<a href="\1">\1</a>', t)
4965
yield i, t
50-
yield 0, '</code></pre>'
66+
yield 0, "</code></pre>"
67+
5168

52-
def prettyprint(paste, paste_id):
53-
n = 1 + re.search("^\s*", paste)[0].count("\n") # Pygments removes initial empty lines, account for that
69+
def prettyprint(paste: str, paste_id: str) -> str:
70+
# Pygments removes initial empty lines, account for that
71+
initialws = re.search(r"^\s*", paste)
72+
n = 1 + initialws[0].count("\n") if initialws else 1
5473
formatter = Formatter(lineanchors=True, linenostart=n)
55-
try: lexer = get_lexer_for_filename(paste_id)
56-
except Exception: lexer = get_lexer_for_filename(paste_id + ".txt")
74+
try:
75+
lexer = get_lexer_for_filename(paste_id)
76+
except Exception:
77+
lexer = get_lexer_for_filename(paste_id + ".txt")
5778
return pygments.highlight(paste, lexer, formatter)
5879

59-
def decode(text: bytes, fallback_charset: str = None) -> str:
80+
81+
def decode(text: bytes, fallback_charset: str | None = None) -> str:
6082
"""Decode with charset autodetection. Removes Unicode BOMs automatically."""
6183
# Unicode strings with BOMs
62-
boms = (b"\xEF\xBB\xBF", "UTF-8"), (b"\xFF\xFE", "UTF-16LE"), (b"\xFE\xFF", "UTF-16BE"), (b"\xFF\xFE\0\0", "UTF-32LE"), (b"\0\0\xFE\xFF", "UTF-32BE")
84+
boms = (
85+
(b"\xef\xbb\xbf", "UTF-8"),
86+
(b"\xff\xfe", "UTF-16LE"),
87+
(b"\xfe\xff", "UTF-16BE"),
88+
(b"\xff\xfe\0\0", "UTF-32LE"),
89+
(b"\0\0\xfe\xff", "UTF-32BE"),
90+
)
6391
for bom, charset in boms:
64-
if text.startswith(bom): return text[len(bom):].decode(charset, errors="replace")
92+
if text.startswith(bom):
93+
return text[len(bom) :].decode(charset, errors="replace")
6594
# Try UTF-8 without BOM
66-
try: return text.decode()
67-
except UnicodeDecodeError: pass
95+
try:
96+
return text.decode()
97+
except UnicodeDecodeError:
98+
pass
6899
# If fallback is provided, just use that
69-
if fallback_charset: return text.decode(fallback_charset, errors="replace")
100+
if fallback_charset:
101+
return text.decode(fallback_charset, errors="replace")
70102
# 8-bit guesswork
71103
# - NUL usually means binary data (could be actual NUL or UTF-16/32 w/o BOM, but all those are rare)
72-
if 0 in text: raise InvalidUsage("Looks like binary data")
104+
if 0 in text:
105+
raise InvalidUsage("Looks like binary data")
73106
# - With CR/LF line terminators, CP437 umlauts are more likely than ISO-8859-1 extended control chars
74-
if any(0x80 <= ch < 0xA0 for ch in text) and b"\r\n" in text: return text.decode("CP437")
107+
if any(0x80 <= ch < 0xA0 for ch in text) and b"\r\n" in text:
108+
return text.decode("CP437")
75109
# - The most common 8-bit encoding is a reasonable final fallback
76110
return text.decode("ISO-8859-1")
77111

112+
78113
@make_async
79-
def process_paste(paste, paste_id, fallback_charset = None):
80-
if isinstance(paste, bytes): paste = decode(paste, fallback_charset)
81-
elif paste is None: raise InvalidUsage("Malformed request (no paste found)")
82-
elif paste[0] == "\uFEFF": paste = paste[1:] # Remove Unicode BOM
114+
def process_paste(paste, paste_id, fallback_charset=None):
115+
if isinstance(paste, bytes):
116+
paste = decode(paste, fallback_charset)
117+
elif paste is None:
118+
raise InvalidUsage("Malformed request (no paste found)")
119+
elif paste[0] == "\ufeff":
120+
paste = paste[1:] # Remove Unicode BOM
83121
paste = paste.replace("\r\n", "\n")
84-
if not paste.strip(): raise InvalidUsage("Empty paste (no data found)")
85-
if not paste.endswith("\n"): paste += "\n"
122+
if not paste.strip():
123+
raise InvalidUsage("Empty paste (no data found)")
124+
if not paste.endswith("\n"):
125+
paste += "\n"
86126
if paste_id:
87-
paste_id = "".join([c for c in paste_id.replace(" ", "_") if re.match(r'[-_\w\.]', c)])
127+
paste_id = "".join(
128+
[c for c in paste_id.replace(" ", "_") if re.match(r"[-_\w\.]", c)]
129+
)
88130
if not paste_id or len(paste_id) < 3:
89131
ext = next((e for e, r in matchers if r.search(paste)), ".txt")
90-
paste_id = PronounceableWord().length(6, 15) + ext
132+
paste_id = PronounceableWord().length(6, 15) + ext # type: ignore
91133
return paste_id, dict(text=paste, html=prettyprint(paste, paste_id))

pastezi/db.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,39 @@
1+
from typing import Any, Dict, Optional
2+
13
from redis import asyncio as aioredis
24

5+
36
class Backend:
7+
expiration: int
8+
ns: str
9+
redis: Optional[aioredis.Redis]
10+
411
def __init__(self):
512
self.expiration = 365 * 86400 # One year
613
self.ns = "pastezi:"
714
self.redis = None
815

9-
async def start(self):
10-
self.redis = await aioredis.from_url('redis://localhost', decode_responses=True)
16+
async def start(self) -> None:
17+
self.redis = await aioredis.from_url("redis://localhost", decode_responses=True)
1118

12-
async def __getitem__(self, id):
19+
async def __getitem__(self, id: str) -> Optional[Dict[str, Any]]:
20+
assert self.redis is not None
1321
id = self.ns + id
14-
value = await self.redis.hgetall(id) or None
15-
if value: await self.redis.expire(id, self.expiration)
22+
value = await self.redis.hgetall(id) or None # type: ignore[awaitable]
23+
if value:
24+
await self.redis.expire(id, self.expiration)
1625
return value
1726

18-
async def store(self, id, value):
27+
async def store(self, id: str, value: Dict[str, Any]) -> bool:
28+
assert self.redis is not None
1929
id = self.ns + id
2030
created = not await self.redis.exists(id)
21-
await self.redis.hset(id, mapping=value)
31+
await self.redis.hset(id, mapping=value) # type: ignore[awaitable]
2232
await self.redis.expire(id, self.expiration)
2333
return created
2434

25-
async def delete(self, id):
35+
async def delete(self, id: str) -> bool:
36+
assert self.redis is not None
2637
id = self.ns + id
2738
deleted = await self.redis.exists(id)
2839
await self.redis.delete(id)

0 commit comments

Comments
 (0)