summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pypaste/__init__.py208
-rw-r--r--pypaste/client/__init__.py11
-rw-r--r--pypaste/client/__main__.py72
-rw-r--r--pypaste/client/plugins/__init__.py11
-rw-r--r--pypaste/client/plugins/zen/__init__.py636
-rw-r--r--pypaste/py.typed0
-rw-r--r--pypaste/server/__init__.py211
-rw-r--r--pypaste/server/__main__.py (renamed from pypaste/__main__.py)7
-rw-r--r--pypaste/server/s3/__init__.py (renamed from pypaste/s3/__init__.py)4
-rw-r--r--pypaste/server/s3/bucket.py (renamed from pypaste/s3/bucket.py)0
-rw-r--r--pypaste/server/sqlite/__init__.py (renamed from pypaste/sqlite/__init__.py)2
-rw-r--r--pypaste/sqlite/meson.build1
12 files changed, 948 insertions, 215 deletions
diff --git a/pypaste/__init__.py b/pypaste/__init__.py
index 0f0d5e2..c0b12d2 100644
--- a/pypaste/__init__.py
+++ b/pypaste/__init__.py
@@ -1,31 +1,5 @@
-# Copyright (C) 2025 John Turner
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see <https://www.gnu.org/licenses/>.
-
import sys
-import asyncio
-import secrets
-import aiosqlite
-from aiohttp import web
from datetime import datetime
-from dataclasses import dataclass
-from typing import Optional, List, Tuple
-from pygments import highlight
-from pygments.lexers import guess_lexer, get_lexer_by_name
-from pygments.formatters import HtmlFormatter
-from pygments.styles import get_style_by_name
-from abc import abstractmethod
RESET = "\x1b[0m"
RED = "\x1b[31m"
@@ -46,185 +20,3 @@ def log_warning(msg: str) -> None:
def log_error(msg: str) -> None:
now = datetime.now().isoformat()
print(f"{RED}[error]{RESET} {now} {msg}", file=sys.stderr)
-
-
-def pygmentize(
- content: str, syntax: Optional[str], style: str, line_numbers: str | bool
-) -> str:
- if syntax is not None:
- try:
- lexer = get_lexer_by_name(syntax)
- except Exception:
- log_warning(f"failed to find lexer for {syntax}")
- lexer = guess_lexer(content)
- else:
- lexer = guess_lexer(content)
-
- try:
- s = get_style_by_name(style)
- except Exception as e:
- log_warning(f"failed to find style: {style}: {e}")
- s = get_style_by_name("default")
-
- formatter = HtmlFormatter(full=True, style=s, linenos="table")
-
- return highlight(content, lexer, formatter)
-
-
-def generate_key(words: List[str], length: int) -> str:
- choices = []
- for _ in range(length):
- choices.append(secrets.choice(words))
-
- return "-".join(word for word in choices).lower()
-
-
-@dataclass
-class Paste:
- key: str
- dt: datetime
- syntax: Optional[str]
- text: str
-
-
-@dataclass
-class Storage:
- connection: aiosqlite.Connection
-
- @abstractmethod
- async def setup(self) -> None:
- pass
-
- @abstractmethod
- async def insert(self, paste: Paste) -> None:
- pass
-
- @abstractmethod
- async def retrieve(self, key: str) -> Optional[Paste]:
- pass
-
- @abstractmethod
- async def delete(self, key) -> None:
- pass
-
- @abstractmethod
- async def vacuum(self, size: int) -> None:
- pass
-
- async def read_row(self, key: str) -> Optional[Tuple[datetime, int, Optional[str]]]:
- async with self.connection.execute(
- "select pastes.datetime,pastes.size,pastes.syntax from pastes where pastes.key=? limit 1",
- (key,),
- ) as cursor:
- match await cursor.fetchone():
- case [str(dt), int(size), syntax]:
- return (datetime.fromisoformat(dt), size, syntax)
- case None:
- return None
- case _:
- raise Exception("unreachable")
-
- async def exists(self, key: str) -> bool:
- async with self.connection.execute(
- "select 1 from pastes where key=?", (key,)
- ) as cursor:
- return await cursor.fetchone() is not None
-
-
-@dataclass
-class AppConfig:
- site: str
- content_max_bytes: int
- storage_max_bytes: int
- key_length: int
- dictionary: List[str]
- default_style: str
- line_numbers: str | bool
-
-
-class App:
-
- def __init__(self, config: AppConfig, storage: Storage):
- self.config = config
- self.storage = storage
-
- async def download(self, request: web.Request) -> web.Response:
- try:
- key = request.match_info["key"]
- except KeyError:
- return web.HTTPBadRequest(text="provide a key to fetch")
-
- try:
- paste = await self.storage.retrieve(key)
- except Exception as e:
- log_error(f"failed to retrieve paste {key}: {e}")
- return web.HTTPInternalServerError()
-
- if paste is None:
- log_info(f"{key} does not exist, returning 404")
- return web.HTTPNotFound()
-
- if (syntax := request.query.get("syntax")) is None:
- syntax = paste.syntax
-
- if (style := request.query.get("style")) is None:
- style = self.config.default_style
-
- raw = request.query.get("raw")
-
- if raw is not None:
- log_info(f"sending raw paste {key}")
-
- return web.HTTPOk(text=paste.text, content_type="text/plain")
- else:
-
- def render():
- return pygmentize(paste.text, syntax, style, self.config.line_numbers)
-
- highlighted = await asyncio.to_thread(render)
-
- log_info(
- f"sending rendered paste {key} with syntax {syntax} and style {style}"
- )
-
- return web.HTTPOk(text=highlighted, content_type="text/html")
-
- async def upload(self, request: web.Request) -> web.Response:
- syntax = request.query.get("syntax")
-
- if (
- content_length := request.content_length
- ) and content_length > self.config.content_max_bytes:
- return web.HTTPBadRequest(
- text=f"max content length is {self.config.content_max_bytes}"
- )
-
- try:
- data = await request.read()
- except Exception as e:
- log_error(f"failed to read data: {e}")
- return web.HTTPInternalServerError(text="failed to read data")
-
- try:
- text = data.decode()
- except UnicodeError:
- return web.HTTPBadRequest(
- text="content must be unicode only, no binary data is allowed"
- )
-
- key = generate_key(self.config.dictionary, self.config.key_length)
-
- try:
- paste = Paste(key, datetime.now(), syntax, text)
- await self.storage.insert(paste)
- except Exception as e:
- log_error(f"failed to insert paste {key} to storage: {e}")
- return web.HTTPInternalServerError()
-
- url = f"{self.config.site}/paste/{key}"
-
- log_info(
- f"uploaded paste {key} with syntax {syntax} of size {len(data)} bytes: {url}"
- )
-
- return web.HTTPOk(text=url)
diff --git a/pypaste/client/__init__.py b/pypaste/client/__init__.py
new file mode 100644
index 0000000..06b9100
--- /dev/null
+++ b/pypaste/client/__init__.py
@@ -0,0 +1,11 @@
+import io
+from typing import Protocol, List, Optional, runtime_checkable
+
+
+class PasteService(Protocol):
+
+ def paste(self, buffer: io.BytesIO, syntax: Optional[str], raw: bool) -> str:
+ pass
+
+ def supported_syntaxes(self) -> List[str]:
+ pass
diff --git a/pypaste/client/__main__.py b/pypaste/client/__main__.py
new file mode 100644
index 0000000..6bc2f14
--- /dev/null
+++ b/pypaste/client/__main__.py
@@ -0,0 +1,72 @@
+import sys
+import pkgutil
+import pypaste.client.plugins as plugins
+from pypaste.client import PasteService
+from importlib import import_module
+from argparse import ArgumentParser
+from pypaste import log_error
+from typing import Dict
+
+
+def register_services():
+ modules = pkgutil.iter_modules(plugins.__path__, plugins.__name__ + ".")
+
+ for _, name, _ in modules:
+ import_module(name)
+
+
+def main() -> int:
+ parser = ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command")
+
+ list_services_parser = subparsers.add_parser("list-services")
+
+ list_syntaxes_parser = subparsers.add_parser("list-syntaxes")
+ list_syntaxes_parser.add_argument("service")
+
+ paste_parser = subparsers.add_parser("paste")
+ paste_parser.add_argument("service")
+ paste_parser.add_argument("--syntax")
+ paste_parser.add_argument("--raw")
+
+ args = parser.parse_args()
+
+ register_services()
+
+ match args.command:
+ case "list-services":
+ for service in plugins.services.keys():
+ print(service)
+
+ return 0
+ case "list-syntaxes":
+ service = plugins.services[args.service]()
+
+ for syntax in service.supported_syntaxes():
+ print(syntax)
+
+ return 0
+ case "paste":
+ service = plugins.services[args.service]()
+
+ if (
+ syntax := args.syntax
+ ) is not None and syntax not in service.supported_syntaxes():
+ print(f"{syntax} is not supported for service {args.service}")
+ return 1
+
+ try:
+ text = sys.stdin.read()
+ except UnicodeError:
+ print("failed to decode text")
+ return 1
+
+ url = service.paste(text, syntax, args.raw)
+
+ print(url)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/pypaste/client/plugins/__init__.py b/pypaste/client/plugins/__init__.py
new file mode 100644
index 0000000..018b673
--- /dev/null
+++ b/pypaste/client/plugins/__init__.py
@@ -0,0 +1,11 @@
+from pypaste.client import PasteService
+
+services = {}
+
+
+def register_service(name):
+
+ def register(plugin):
+ services[name] = plugin
+
+ return register
diff --git a/pypaste/client/plugins/zen/__init__.py b/pypaste/client/plugins/zen/__init__.py
new file mode 100644
index 0000000..6ef8348
--- /dev/null
+++ b/pypaste/client/plugins/zen/__init__.py
@@ -0,0 +1,636 @@
+import io
+import requests
+from pypaste.client.plugins import register_service
+from typing import Optional, List
+
+SYNTAXES = [
+ "ABAP",
+ "AMDGPU",
+ "APL",
+ "ABNF",
+ "ActionScript 3",
+ "ActionScript",
+ "Ada",
+ "ADL",
+ "Agda",
+ "Aheui",
+ "Alloy",
+ "AmbientTalk",
+ "Ampl",
+ "HTML + Angular2",
+ "Angular2",
+ "ANTLR With ActionScript Target",
+ "ANTLR With C# Target",
+ "ANTLR With CPP Target",
+ "ANTLR With Java Target",
+ "ANTLR",
+ "ANTLR With ObjectiveC Target",
+ "ANTLR With Perl Target",
+ "ANTLR With Python Target",
+ "ANTLR With Ruby Target",
+ "ApacheConf",
+ "AppleScript",
+ "Arduino",
+ "Arrow",
+ "Arturo",
+ "ASCII armored",
+ "ASN.1",
+ "AspectJ",
+ "Asymptote",
+ "Augeas",
+ "AutoIt",
+ "autohotkey",
+ "Awk",
+ "BBC Basic",
+ "BBCode",
+ "BC",
+ "BQN",
+ "BST",
+ "BARE",
+ "Base Makefile",
+ "Bash",
+ "Bash Session",
+ "Batchfile",
+ "Bdd",
+ "Befunge",
+ "Berry",
+ "BibTeX",
+ "BlitzBasic",
+ "BlitzMax",
+ "Blueprint",
+ "BNF",
+ "Boa",
+ "Boo",
+ "Boogie",
+ "Brainfuck",
+ "BUGS",
+ "CAmkES",
+ "C",
+ "CMake",
+ "c-objdump",
+ "CPSA",
+ "CSS+UL4",
+ "aspx-cs",
+ "C#",
+ "ca65 assembler",
+ "cADL",
+ "CapDL",
+ "Cap'n Proto",
+ "Carbon",
+ "CBM BASIC V2",
+ "CDDL",
+ "Ceylon",
+ "CFEngine3",
+ "ChaiScript",
+ "Chapel",
+ "Charmci",
+ "HTML+Cheetah",
+ "JavaScript+Cheetah",
+ "Cheetah",
+ "XML+Cheetah",
+ "Cirru",
+ "Clay",
+ "Clean",
+ "Clojure",
+ "ClojureScript",
+ "COBOLFree",
+ "COBOL",
+ "CodeQL",
+ "CoffeeScript",
+ "Coldfusion CFC",
+ "Coldfusion HTML",
+ "cfstatement",
+ "COMAL-80",
+ "Common Lisp",
+ "Component Pascal",
+ "Coq",
+ "cplint",
+ "C++",
+ "cpp-objdump",
+ "Crmsh",
+ "Croc",
+ "Cryptol",
+ "Crystal",
+ "Csound Document",
+ "Csound Orchestra",
+ "Csound Score",
+ "CSS+Django/Jinja",
+ "CSS+Ruby",
+ "CSS+Genshi Text",
+ "CSS",
+ "CSS+PHP",
+ "CSS+Smarty",
+ "CUDA",
+ "Cypher",
+ "Cython",
+ "D",
+ "d-objdump",
+ "Darcs Patch",
+ "Dart",
+ "DASM16",
+ "Dax",
+ "Debian Control file",
+ "Debian Sources file",
+ "Delphi",
+ "Desktop file",
+ "Devicetree",
+ "dg",
+ "Diff",
+ "Django/Jinja",
+ "Zone",
+ "Docker",
+ "DTD",
+ "Duel",
+ "Dylan session",
+ "Dylan",
+ "DylanLID",
+ "ECL",
+ "eC",
+ "Earl Grey",
+ "Easytrieve",
+ "EBNF",
+ "Eiffel",
+ "Elixir iex session",
+ "Elixir",
+ "Elm",
+ "Elpi",
+ "EmacsLisp",
+ "E-mail",
+ "ERB",
+ "Erlang",
+ "Erlang erl session",
+ "HTML+Evoque",
+ "Evoque",
+ "XML+Evoque",
+ "execline",
+ "Ezhil",
+ "F#",
+ "FStar",
+ "Factor",
+ "Fancy",
+ "Fantom",
+ "Felix",
+ "Fennel",
+ "Fift",
+ "Fish",
+ "Flatline",
+ "FloScript",
+ "Forth",
+ "FortranFixed",
+ "Fortran",
+ "FoxPro",
+ "Freefem",
+ "FunC",
+ "Futhark",
+ "GAP session",
+ "GAP",
+ "GDScript",
+ "GLSL",
+ "GSQL",
+ "GAS",
+ "g-code",
+ "Genshi",
+ "Genshi Text",
+ "Gettext Catalog",
+ "Gherkin",
+ "Gleam",
+ "Gnuplot",
+ "Go",
+ "Golo",
+ "GoodData-CL",
+ "GoogleSQL",
+ "Gosu",
+ "Gosu Template",
+ "GraphQL",
+ "Graphviz",
+ "Groff",
+ "Groovy",
+ "HLSL",
+ "HTML+UL4",
+ "Haml",
+ "HTML+Handlebars",
+ "Handlebars",
+ "Hare",
+ "Haskell",
+ "Haxe",
+ "Hexdump",
+ "HSAIL",
+ "Hspec",
+ "HTML+Django/Jinja",
+ "HTML+Genshi",
+ "HTML",
+ "HTML+PHP",
+ "HTML+Smarty",
+ "HTTP",
+ "Hxml",
+ "Hy",
+ "Hybris",
+ "IDL",
+ "Icon",
+ "Idris",
+ "Igor",
+ "Inform 6",
+ "Inform 6 template",
+ "Inform 7",
+ "INI",
+ "Io",
+ "Ioke",
+ "IRC logs",
+ "Isabelle",
+ "J",
+ "JMESPath",
+ "JSLT",
+ "JAGS",
+ "Janet",
+ "Jasmin",
+ "Java",
+ "JavaScript+Django/Jinja",
+ "JavaScript+Ruby",
+ "JavaScript+Genshi Text",
+ "JavaScript",
+ "JavaScript+PHP",
+ "JavaScript+Smarty",
+ "Javascript+UL4",
+ "JCL",
+ "JSGF",
+ "JSON5",
+ "JSONBareObject",
+ "JSON-LD",
+ "JSON",
+ "Jsonnet",
+ "Java Server Page",
+ "JSX",
+ "Julia console",
+ "Julia",
+ "Juttle",
+ "K",
+ "Kal",
+ "Kconfig",
+ "Kernel log",
+ "Koka",
+ "Kotlin",
+ "Kuin",
+ "Kusto",
+ "LSL",
+ "CSS+Lasso",
+ "HTML+Lasso",
+ "JavaScript+Lasso",
+ "Lasso",
+ "XML+Lasso",
+ "LDAP configuration file",
+ "LDIF",
+ "Lean",
+ "Lean4",
+ "LessCss",
+ "Lighttpd configuration file",
+ "LilyPond",
+ "Limbo",
+ "liquid",
+ "Literate Agda",
+ "Literate Cryptol",
+ "Literate Haskell",
+ "Literate Idris",
+ "LiveScript",
+ "LLVM",
+ "LLVM-MIR Body",
+ "LLVM-MIR",
+ "Logos",
+ "Logtalk",
+ "Lua",
+ "Luau",
+ "MCFunction",
+ "MCSchema",
+ "MIME",
+ "MIPS",
+ "MOOCode",
+ "MSDOS Session",
+ "Macaulay2",
+ "Makefile",
+ "CSS+Mako",
+ "HTML+Mako",
+ "JavaScript+Mako",
+ "Mako",
+ "XML+Mako",
+ "Maple",
+ "MAQL",
+ "Markdown",
+ "Mask",
+ "Mason",
+ "Mathematica",
+ "Matlab",
+ "Matlab session",
+ "Maxima",
+ "Meson",
+ "MiniD",
+ "MiniScript",
+ "Modelica",
+ "Modula-2",
+ "MoinMoin/Trac Wiki markup",
+ "Mojo",
+ "Monkey",
+ "Monte",
+ "MoonScript",
+ "Mosel",
+ "CSS+mozpreproc",
+ "mozhashpreproc",
+ "Javascript+mozpreproc",
+ "mozpercentpreproc",
+ "XUL+mozpreproc",
+ "MQL",
+ "Mscgen",
+ "MuPAD",
+ "MXML",
+ "MySQL",
+ "CSS+Myghty",
+ "HTML+Myghty",
+ "JavaScript+Myghty",
+ "Myghty",
+ "XML+Myghty",
+ "NCL",
+ "NSIS",
+ "NASM",
+ "objdump-nasm",
+ "Nemerle",
+ "nesC",
+ "NestedText",
+ "NewLisp",
+ "Newspeak",
+ "Nginx configuration file",
+ "Nimrod",
+ "Nit",
+ "Nix",
+ "Node.js REPL console session",
+ "Notmuch",
+ "NuSMV",
+ "NumPy",
+ "Numba_IR",
+ "objdump",
+ "Objective-C",
+ "Objective-C++",
+ "Objective-J",
+ "OCaml",
+ "Octave",
+ "ODIN",
+ "OMG Interface Definition Language",
+ "Ooc",
+ "Opa",
+ "OpenEdge ABL",
+ "OpenSCAD",
+ "Org Mode",
+ "Text output",
+ "PacmanConf",
+ "Pan",
+ "ParaSail",
+ "Pawn",
+ "PDDL",
+ "PEG",
+ "Perl6",
+ "Perl",
+ "Phix",
+ "PHP",
+ "Pig",
+ "Pike",
+ "PkgConfig",
+ "PL/pgSQL",
+ "Pointless",
+ "Pony",
+ "Portugol",
+ "PostScript",
+ "PostgreSQL console (psql)",
+ "PostgreSQL EXPLAIN dialect",
+ "PostgreSQL SQL dialect",
+ "POVRay",
+ "PowerShell",
+ "PowerShell Session",
+ "Praat",
+ "Procfile",
+ "Prolog",
+ "PromQL",
+ "Promela",
+ "Properties",
+ "Protocol Buffer",
+ "PRQL",
+ "PsySH console session for PHP",
+ "PTX",
+ "Pug",
+ "Puppet",
+ "PyPy Log",
+ "Python 2.x",
+ "Python 2.x Traceback",
+ "Python console session",
+ "Python",
+ "Python Traceback",
+ "Python+UL4",
+ "QBasic",
+ "Q",
+ "QVTO",
+ "Qlik",
+ "QML",
+ "RConsole",
+ "Relax-NG Compact",
+ "RPMSpec",
+ "Racket",
+ "Ragel in C Host",
+ "Ragel in CPP Host",
+ "Ragel in D Host",
+ "Embedded Ragel",
+ "Ragel in Java Host",
+ "Ragel",
+ "Ragel in Objective C Host",
+ "Ragel in Ruby Host",
+ "Raw token data",
+ "Rd",
+ "ReasonML",
+ "REBOL",
+ "Red",
+ "Redcode",
+ "reg",
+ "Rego",
+ "ResourceBundle",
+ "Rexx",
+ "RHTML",
+ "Ride",
+ "Rita",
+ "Roboconf Graph",
+ "Roboconf Instances",
+ "RobotFramework",
+ "RQL",
+ "RSL",
+ "reStructuredText",
+ "TrafficScript",
+ "Ruby irb session",
+ "Ruby",
+ "Rust",
+ "SAS",
+ "S",
+ "Standard ML",
+ "SNBT",
+ "SARL",
+ "Sass",
+ "Savi",
+ "Scala",
+ "Scaml",
+ "scdoc",
+ "Scheme",
+ "Scilab",
+ "SCSS",
+ "Sed",
+ "ShExC",
+ "Shen",
+ "Sieve",
+ "Silver",
+ "Singularity",
+ "Slash",
+ "Slim",
+ "Slurm",
+ "Smali",
+ "Smalltalk",
+ "SmartGameFormat",
+ "Smarty",
+ "Smithy",
+ "Snobol",
+ "Snowball",
+ "Solidity",
+ "Soong",
+ "Sophia",
+ "SourcePawn",
+ "Debian Sourcelist",
+ "SPARQL",
+ "Spice",
+ "SQL+Jinja",
+ "SQL",
+ "sqlite3con",
+ "SquidConf",
+ "Srcinfo",
+ "Scalate Server Page",
+ "Stan",
+ "Stata",
+ "SuperCollider",
+ "Swift",
+ "SWIG",
+ "systemverilog",
+ "Systemd",
+ "TAP",
+ "Typographic Number Theory",
+ "TOML",
+ "TableGen",
+ "Tact",
+ "TADS 3",
+ "Tal",
+ "TASM",
+ "Tcl",
+ "Tcsh",
+ "Tcsh Session",
+ "Tea",
+ "teal",
+ "Tera Term macro",
+ "Termcap",
+ "Terminfo",
+ "Terraform",
+ "TeX",
+ "Text only",
+ "ThingsDB",
+ "Thrift",
+ "tiddler",
+ "Tl-b",
+ "TLS Presentation Language",
+ "Todotxt",
+ "Transact-SQL",
+ "Treetop",
+ "TSX",
+ "Turtle",
+ "HTML+Twig",
+ "Twig",
+ "TypeScript",
+ "TypoScriptCssData",
+ "TypoScriptHtmlData",
+ "TypoScript",
+ "Typst",
+ "UL4",
+ "ucode",
+ "Unicon",
+ "Unix/Linux config files",
+ "UrbiScript",
+ "urlencoded",
+ "USD",
+ "VBScript",
+ "VCL",
+ "VCLSnippets",
+ "VCTreeStatus",
+ "VGL",
+ "Vala",
+ "aspx-vb",
+ "VB.net",
+ "HTML+Velocity",
+ "Velocity",
+ "XML+Velocity",
+ "Verifpal",
+ "verilog",
+ "vhdl",
+ "VimL",
+ "Visual Prolog Grammar",
+ "Visual Prolog",
+ "Vue",
+ "Vyper",
+ "WDiff",
+ "WebAssembly",
+ "Web IDL",
+ "WebGPU Shading Language",
+ "Whiley",
+ "Wikitext",
+ "World of Warcraft TOC",
+ "Wren",
+ "X10",
+ "XML+UL4",
+ "XQuery",
+ "XML+Django/Jinja",
+ "XML+Ruby",
+ "XML",
+ "XML+PHP",
+ "XML+Smarty",
+ "Xorg",
+ "X++",
+ "XSLT",
+ "Xtend",
+ "xtlang",
+ "YAML+Jinja",
+ "YAML",
+ "YANG",
+ "YARA",
+ "Zeek",
+ "Zephir",
+ "Zig",
+ "ANSYS parametric design language",
+ "CSS+Mako",
+ "HTML+Mako",
+ "JavaScript+Mako",
+ "Mako",
+ "XML+Mako",
+]
+
+URL: str = "https://zen.jturnerusa.dev/paste"
+
+
+@register_service("zen")
+class Zen:
+
+ def paste(self, buffer: io.BytesIO, syntax: Optional[str], raw: bool) -> str:
+ params = {}
+
+ if syntax is not None:
+ params["syntax"] = syntax
+
+ if raw:
+ params["raw"] = "true"
+
+ req = requests.post(URL, params=params, data=buffer)
+
+ if req.status_code != 200:
+ raise Exception(
+ f"failed to paste to pypaste with status code: {req.status_code}"
+ )
+
+ return req.text
+
+ def supported_syntaxes(self) -> List[str]:
+ return SYNTAXES
diff --git a/pypaste/py.typed b/pypaste/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pypaste/py.typed
diff --git a/pypaste/server/__init__.py b/pypaste/server/__init__.py
new file mode 100644
index 0000000..ad07ecd
--- /dev/null
+++ b/pypaste/server/__init__.py
@@ -0,0 +1,211 @@
+# Copyright (C) 2025 John Turner
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import sys
+import asyncio
+import secrets
+import aiosqlite
+from aiohttp import web
+from datetime import datetime
+from dataclasses import dataclass
+from typing import Optional, List, Tuple
+from pypaste import log_error, log_warning, log_info
+from pygments import highlight
+from pygments.lexers import guess_lexer, get_lexer_by_name
+from pygments.formatters import HtmlFormatter
+from pygments.styles import get_style_by_name
+from abc import abstractmethod
+
+
+def pygmentize(
+ content: str, syntax: Optional[str], style: str, line_numbers: str | bool
+) -> str:
+ if syntax is not None:
+ try:
+ lexer = get_lexer_by_name(syntax)
+ except Exception:
+ log_warning(f"failed to find lexer for {syntax}")
+ lexer = guess_lexer(content)
+ else:
+ lexer = guess_lexer(content)
+
+ try:
+ s = get_style_by_name(style)
+ except Exception as e:
+ log_warning(f"failed to find style: {style}: {e}")
+ s = get_style_by_name("default")
+
+ formatter = HtmlFormatter(full=True, style=s, linenos="table")
+
+ return highlight(content, lexer, formatter)
+
+
+def generate_key(words: List[str], length: int) -> str:
+ choices = []
+ for _ in range(length):
+ choices.append(secrets.choice(words))
+
+ return "-".join(word for word in choices).lower()
+
+
+@dataclass
+class Paste:
+ key: str
+ dt: datetime
+ syntax: Optional[str]
+ text: str
+
+
+@dataclass
+class Storage:
+ connection: aiosqlite.Connection
+
+ @abstractmethod
+ async def setup(self) -> None:
+ pass
+
+ @abstractmethod
+ async def insert(self, paste: Paste) -> None:
+ pass
+
+ @abstractmethod
+ async def retrieve(self, key: str) -> Optional[Paste]:
+ pass
+
+ @abstractmethod
+ async def delete(self, key) -> None:
+ pass
+
+ @abstractmethod
+ async def vacuum(self, size: int) -> None:
+ pass
+
+ async def read_row(self, key: str) -> Optional[Tuple[datetime, int, Optional[str]]]:
+ async with self.connection.execute(
+ "select pastes.datetime,pastes.size,pastes.syntax from pastes where pastes.key=? limit 1",
+ (key,),
+ ) as cursor:
+ match await cursor.fetchone():
+ case [str(dt), int(size), syntax]:
+ return (datetime.fromisoformat(dt), size, syntax)
+ case None:
+ return None
+ case _:
+ raise Exception("unreachable")
+
+ async def exists(self, key: str) -> bool:
+ async with self.connection.execute(
+ "select 1 from pastes where key=?", (key,)
+ ) as cursor:
+ return await cursor.fetchone() is not None
+
+
+@dataclass
+class AppConfig:
+ site: str
+ content_max_bytes: int
+ storage_max_bytes: int
+ key_length: int
+ dictionary: List[str]
+ default_style: str
+ line_numbers: str | bool
+
+
+class App:
+
+ def __init__(self, config: AppConfig, storage: Storage):
+ self.config = config
+ self.storage = storage
+
+ async def download(self, request: web.Request) -> web.Response:
+ try:
+ key = request.match_info["key"]
+ except KeyError:
+ return web.HTTPBadRequest(text="provide a key to fetch")
+
+ try:
+ paste = await self.storage.retrieve(key)
+ except Exception as e:
+ log_error(f"failed to retrieve paste {key}: {e}")
+ return web.HTTPInternalServerError()
+
+ if paste is None:
+ log_info(f"{key} does not exist, returning 404")
+ return web.HTTPNotFound()
+
+ if (syntax := request.query.get("syntax")) is None:
+ syntax = paste.syntax
+
+ if (style := request.query.get("style")) is None:
+ style = self.config.default_style
+
+ raw = request.query.get("raw")
+
+ if raw is not None:
+ log_info(f"sending raw paste {key}")
+
+ return web.HTTPOk(text=paste.text, content_type="text/plain")
+ else:
+
+ def render():
+ return pygmentize(paste.text, syntax, style, self.config.line_numbers)
+
+ highlighted = await asyncio.to_thread(render)
+
+ log_info(
+ f"sending rendered paste {key} with syntax {syntax} and style {style}"
+ )
+
+ return web.HTTPOk(text=highlighted, content_type="text/html")
+
+ async def upload(self, request: web.Request) -> web.Response:
+ syntax = request.query.get("syntax")
+
+ if (
+ content_length := request.content_length
+ ) and content_length > self.config.content_max_bytes:
+ return web.HTTPBadRequest(
+ text=f"max content length is {self.config.content_max_bytes}"
+ )
+
+ try:
+ data = await request.read()
+ except Exception as e:
+ log_error(f"failed to read data: {e}")
+ return web.HTTPInternalServerError(text="failed to read data")
+
+ try:
+ text = data.decode()
+ except UnicodeError:
+ return web.HTTPBadRequest(
+ text="content must be unicode only, no binary data is allowed"
+ )
+
+ key = generate_key(self.config.dictionary, self.config.key_length)
+
+ try:
+ paste = Paste(key, datetime.now(), syntax, text)
+ await self.storage.insert(paste)
+ except Exception as e:
+ log_error(f"failed to insert paste {key} to storage: {e}")
+ return web.HTTPInternalServerError()
+
+ url = f"{self.config.site}/paste/{key}"
+
+ log_info(
+ f"uploaded paste {key} with syntax {syntax} of size {len(data)} bytes: {url}"
+ )
+
+ return web.HTTPOk(text=url)
diff --git a/pypaste/__main__.py b/pypaste/server/__main__.py
index 5215d15..c28a9ba 100644
--- a/pypaste/__main__.py
+++ b/pypaste/server/__main__.py
@@ -17,9 +17,10 @@ import sys
import os
import asyncio
import aiosqlite
-from pypaste import App, AppConfig, Storage, log_error, log_info
-from pypaste.s3 import S3
-from pypaste.sqlite import Sqlite
+from pypaste import log_error, log_info
+from pypaste.server import App, AppConfig, Storage
+from pypaste.server.s3 import S3
+from pypaste.server.sqlite import Sqlite
from socket import socket, AF_UNIX, SOCK_STREAM
from argparse import ArgumentParser
from aiohttp import web
diff --git a/pypaste/s3/__init__.py b/pypaste/server/s3/__init__.py
index 7d21703..07e2580 100644
--- a/pypaste/s3/__init__.py
+++ b/pypaste/server/s3/__init__.py
@@ -17,8 +17,8 @@ import asyncio
import zstandard
import asyncio
import aiosqlite
-from pypaste import Storage, Paste
-from pypaste.s3.bucket import Bucket
+from pypaste.server import Storage, Paste
+from pypaste.server.s3.bucket import Bucket
from dataclasses import dataclass
from typing import Optional
diff --git a/pypaste/s3/bucket.py b/pypaste/server/s3/bucket.py
index c795bbd..c795bbd 100644
--- a/pypaste/s3/bucket.py
+++ b/pypaste/server/s3/bucket.py
diff --git a/pypaste/sqlite/__init__.py b/pypaste/server/sqlite/__init__.py
index 5700356..2eb2ae5 100644
--- a/pypaste/sqlite/__init__.py
+++ b/pypaste/server/sqlite/__init__.py
@@ -1,7 +1,7 @@
import asyncio
import zstandard
import aiosqlite
-from pypaste import Storage, Paste
+from pypaste.server import Storage, Paste
from dataclasses import dataclass
from typing import Optional
diff --git a/pypaste/sqlite/meson.build b/pypaste/sqlite/meson.build
deleted file mode 100644
index b1c4131..0000000
--- a/pypaste/sqlite/meson.build
+++ /dev/null
@@ -1 +0,0 @@
-sources += files('__init__.p'y)