diff options
author | John Turner <jturner.usa@gmail.com> | 2025-09-10 21:59:42 -0400 |
---|---|---|
committer | John Turner <jturner.usa@gmail.com> | 2025-09-10 22:19:22 -0400 |
commit | 110cf01f75f95d6115f897e7ba4fe684508a0c96 (patch) | |
tree | 052b3f53ed9ada4b9d68540dea55e0388ebc64a2 | |
parent | c20a4f0714d30f3ae9eddc16ffd8f987f0ac9cc4 (diff) | |
download | pypaste-110cf01f75f95d6115f897e7ba4fe684508a0c96.tar.gz |
implement basic client
-rw-r--r-- | pypaste/__init__.py | 208 | ||||
-rw-r--r-- | pypaste/client/__init__.py | 11 | ||||
-rw-r--r-- | pypaste/client/__main__.py | 72 | ||||
-rw-r--r-- | pypaste/client/plugins/__init__.py | 11 | ||||
-rw-r--r-- | pypaste/client/plugins/zen/__init__.py | 636 | ||||
-rw-r--r-- | pypaste/py.typed | 0 | ||||
-rw-r--r-- | pypaste/server/__init__.py | 211 | ||||
-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.build | 1 |
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) |