aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTomasz Kramkowski <tomasz@kramkow.ski>2023-02-14 18:36:54 +0000
committerTomasz Kramkowski <tomasz@kramkow.ski>2023-02-14 18:36:54 +0000
commitac0b1401574711a26d494a80c03807e422cc0853 (patch)
tree19d1440d05c6448e8d5065c6aa4270747e418020
parent0d143bb226e702006d6929c346b8d25a05e10b2e (diff)
downloadpaste-ac0b1401574711a26d494a80c03807e422cc0853.tar.gz
paste-ac0b1401574711a26d494a80c03807e422cc0853.tar.xz
paste-ac0b1401574711a26d494a80c03807e422cc0853.zip
First draft version
-rw-r--r--.gitignore3
-rw-r--r--ARCHITECTURE26
-rw-r--r--LICENSE20
-rw-r--r--README3
-rwxr-xr-xformat3
-rw-r--r--paste/__init__.py181
-rw-r--r--paste/__main__.py140
-rw-r--r--paste/db.py69
-rw-r--r--paste/store.py52
-rw-r--r--poetry.lock221
-rw-r--r--pyproject.toml19
-rwxr-xr-xvermin2
12 files changed, 739 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..af023e2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/paste.sqlite3
+/dist/
+__pycache__/
diff --git a/ARCHITECTURE b/ARCHITECTURE
new file mode 100644
index 0000000..617aea1
--- /dev/null
+++ b/ARCHITECTURE
@@ -0,0 +1,26 @@
+API Keys:
+
+paste uses 96 bit API keys which are stored as SHA-256 hashes.
+
+The keys are transmitted as base64 (RFC 4648) encoded Bearer tokens with
+no padding.
+
+The length is long enough to be secure against any and all forms of brute
+force but short enough that the base64 encoding is only 16 characters
+meaning it can be easily typed out.
+
+Coincidentally, 96 is also evenly divisible by 6 so the base64 encoding
+has no padding.
+
+Base64 was chosen as the limited use of symbols makes it easier to type.
+
+Storing the keys as plain SHA-256 is sufficiently secure as:
+- Brute forcing the keys would, as mentioned before, be infeasible.
+- If someone recovers the hash, they would have an easier time brute
+ forcing, but still infeasible with current computing power.
+- There is a timing attack but it would require predictably generating
+ hashes with longer and longer prefixes which is infeasible.
+
+But, to further reduce the chances of a recovered hash being brute
+forced, a KDF with a small number of rounds could be used to seriously
+increase the time required.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c71337f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright 2023 Tomasz Kramkowski <tomasz@kramkow.ski>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README b/README
new file mode 100644
index 0000000..845b403
--- /dev/null
+++ b/README
@@ -0,0 +1,3 @@
+PASTE
+
+A basic WSGI paste site.
diff --git a/format b/format
new file mode 100755
index 0000000..bac95b0
--- /dev/null
+++ b/format
@@ -0,0 +1,3 @@
+#!/bin/sh
+[ "$#" -eq 0 ] && set .
+poetry run black -q "$@"
diff --git a/paste/__init__.py b/paste/__init__.py
new file mode 100644
index 0000000..2a15f43
--- /dev/null
+++ b/paste/__init__.py
@@ -0,0 +1,181 @@
+from base64 import b64decode, b64encode
+from functools import wraps
+from hashlib import sha256
+from wsgiref.util import request_uri
+from typing import Optional, Any
+from collections.abc import Callable, Iterable
+import binascii
+import traceback
+from sqlite3 import Connection
+
+from . import db
+from . import store
+
+StartResponse = Callable[[str, list[tuple[str, str]]], Optional[tuple]]
+Env = dict[str, Any]
+App = Callable[[Env, StartResponse], Iterable[bytes]]
+Response = Iterable[bytes]
+
+DB_PATH = "paste.sqlite3"
+
+
+def simple_response(start_response: StartResponse, status: str) -> Response:
+ body = (status + "\n").encode()
+ start_response(
+ status,
+ [
+ ("Content-Type", "text/plain"),
+ ("Content-Length", str(len(body))),
+ ],
+ )
+ return [body]
+
+
+def redirect(start_response: StartResponse, location: bytes, typ: str) -> Response:
+ status = {
+ "text/x.redirect.301": "301 Moved Permanently",
+ "text/x.redirect.302": "302 Found",
+ }.get(typ)
+ if not status:
+ return simple_response(start_response, "500 Internal Server Error")
+ body = location
+ start_response(
+ status,
+ [
+ ("Location", location.decode()),
+ ("Content-Type", "text/plain"),
+ ("Content-Length", str(len(body))),
+ ],
+ )
+ return [body]
+
+
+ProtoMiddleware = Callable[[App, Env, StartResponse], Response]
+Middleware = Callable[[App], App]
+
+
+def middleware(f: ProtoMiddleware) -> Middleware:
+ @wraps(f)
+ def outer(app: App):
+ @wraps(app)
+ def inner(environ: Env, start_response: StartResponse):
+ return f(app, environ, start_response)
+
+ return inner
+
+ return outer
+
+
+@middleware
+def catch_exceptions(app: App, environ: Env, start_response: StartResponse) -> Response:
+ try:
+ return app(environ, start_response)
+ except Exception as e:
+ print("".join(traceback.format_exception(type(e), e, e.__traceback__)))
+ return simple_response(start_response, "500 Internal Server Error")
+
+
+@middleware
+def validate_method(app: App, environ: Env, start_response: StartResponse) -> Response:
+ if environ["REQUEST_METHOD"] in {"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}:
+ return app(environ, start_response)
+ if environ["REQUEST_METHOD"] in {"CONNECT", "TRACE", "PATCH"}:
+ return simple_response(start_response, "405 Method Not Allowed")
+ return simple_response(start_response, "501 Not Implemented")
+
+
+@middleware
+def options(app: App, environ: Env, start_response: StartResponse) -> Response:
+ if environ["REQUEST_METHOD"] != "OPTIONS":
+ return app(environ, start_response)
+ start_response(
+ "204 No Content",
+ [
+ ("Allow", "GET, HEAD, POST, PUT, DELETE, OPTIONS"),
+ ],
+ )
+ return []
+
+
+@middleware
+def open_database(app: App, environ: Env, start_response: StartResponse) -> Response:
+ db_path = environ.get("PASTE_DB", DB_PATH)
+ with db.connect(db_path) as conn:
+ environ["paste.db_conn"] = conn
+ return app(environ, start_response)
+
+
+def check_auth(conn: Connection, auth: Optional[str]) -> bool:
+ if not auth or not auth.startswith("Bearer "):
+ return False
+ try:
+ token = b64decode(auth.removeprefix("Bearer ").encode())
+ except binascii.Error:
+ return False
+ (count,) = conn.execute(
+ "SELECT COUNT(*) FROM token WHERE hash = sha256(?)", (token,)
+ ).fetchone()
+ return count == 1
+
+
+@middleware
+def authenticate(app: App, environ: Env, start_response: StartResponse) -> Response:
+ conn = environ["paste.db_conn"]
+ token = environ.get("HTTP_AUTHORIZATION")
+ if environ["REQUEST_METHOD"] in {"GET", "HEAD"} or check_auth(conn, token):
+ return app(environ, start_response)
+ return simple_response(start_response, "401 Unauthorized")
+
+
+@catch_exceptions
+@validate_method
+@options
+@open_database
+@authenticate
+def application(environ: Env, start_response: StartResponse) -> Response:
+ conn = environ["paste.db_conn"]
+ name = environ["PATH_INFO"]
+ if environ["REQUEST_METHOD"] == "GET":
+ row = store.get(conn, name)
+ if not row:
+ return simple_response(start_response, "404 Not Found")
+ content_type, content_hash, content = row
+ if content_type.startswith("text/x.redirect"):
+ return redirect(start_response, content, content_type)
+ start_response(
+ "200 OK",
+ [
+ ("Content-Type", content_type),
+ ("Content-Length", str(len(content))),
+ ("ETag", b64encode(content_hash).decode()),
+ ],
+ )
+ return [content]
+ elif environ["REQUEST_METHOD"] == "HEAD":
+ row = store.head(conn, name)
+ if not row:
+ return simple_response(start_response, "404 Not Found")
+ content_type, content_hash, content_length, opt_content = row
+ if content_type.startswith("text/x.redirect"):
+ return redirect(start_response, opt_content, content_type)
+ start_response(
+ "200 OK",
+ [
+ ("Content-Type", content_type),
+ ("Content-Length", content_length),
+ ("ETag", b64encode(content_hash).decode()),
+ ],
+ )
+ return []
+ elif environ["REQUEST_METHOD"] in {"POST", "PUT"}:
+ content_type = environ.get("CONTENT_TYPE", "text/plain")
+ content_length = int(environ["CONTENT_LENGTH"])
+ content = environ["wsgi.input"].read(content_length)
+ store.put(conn, name, content, content_type)
+ return redirect(
+ start_response, request_uri(environ).encode(), "text/x.redirect.302"
+ )
+ elif environ["REQUEST_METHOD"] == "DELETE":
+ store.delete(conn, name)
+ return simple_response(start_response, "204 No Content")
+ return simple_response(start_response, "500 Internal Server Error")
diff --git a/paste/__main__.py b/paste/__main__.py
new file mode 100644
index 0000000..f1aa117
--- /dev/null
+++ b/paste/__main__.py
@@ -0,0 +1,140 @@
+from base64 import b64encode, b64decode
+from datetime import datetime, timezone
+from hashlib import sha256
+from os import getenv
+from secrets import token_bytes
+from sys import argv, stderr
+from wsgiref.simple_server import make_server
+from sqlite3 import Connection
+from contextlib import AbstractContextManager
+
+from . import application, DB_PATH
+from . import db
+
+TOKEN_BYTES = 96 // 8
+PROGRAM_NAME = "paste"
+
+
+def print_usage() -> None:
+ print(
+ f"Usage: {PROGRAM_NAME} [-h|serve|new-token|list-tokens|delete-token]",
+ file=stderr,
+ )
+
+
+def print_help() -> None:
+ print(
+ f"""A simple WSGI paste site
+
+Commands:
+ serve (default) Start a basic HTTP server (do NOT use in production)
+ new-token Generate a new access token
+ list-tokens List access token hashes (sha256 hash, created at)
+ check-token Check if a token is valid and exists in the database
+ delete-token Delete a token (specify token or hash)
+
+Options:
+ -h Show this help
+
+Environment:
+ PASTE_HOST The HTTP server host (default: localhost)
+ PASTE_PORT The HTTP server port (default: 8080)
+ PASTE_DB The path to the sqlite3 database (default: {DB_PATH})""",
+ file=stderr,
+ )
+
+
+def generate_token(conn: Connection):
+ token = token_bytes(TOKEN_BYTES)
+ token_hash = sha256(token).digest()
+ with conn:
+ conn.execute("INSERT INTO token (hash) VALUES (?)", (token_hash,))
+ return token
+
+
+def get_tokens(conn: Connection):
+ return conn.execute(
+ "SELECT hash, created_at FROM token ORDER BY created_at"
+ ).fetchall()
+
+
+def delete_token_hash(conn: Connection, token_hash: bytes):
+ if len(token_hash) != 256 // 8:
+ raise ValueError("Invalid token hash")
+ with conn:
+ cur = conn.execute("DELETE FROM token WHERE hash = ?", (token_hash,))
+ if cur.rowcount <= 0:
+ raise KeyError("Token hash does not exist")
+
+
+def delete_token(conn: Connection, token: bytes):
+ if len(token) != TOKEN_BYTES:
+ raise ValueError("Invalid token")
+ return delete_token_hash(conn, sha256(token).digest())
+
+
+def check_token(conn: Connection, token: bytes):
+ if len(token) != TOKEN_BYTES:
+ raise ValueError("Invalid token")
+ token_hash = sha256(token).digest()
+ (count,) = conn.execute(
+ "SELECT COUNT(*) FROM token WHERE hash = ?", (token_hash,)
+ ).fetchone()
+ return count > 0
+
+
+db_path = getenv("PASTE_DB", DB_PATH)
+
+
+def connect() -> AbstractContextManager[Connection]:
+ return db.connect(db_path)
+
+
+def main():
+ if len(argv) <= 1 or len(argv) == 2 and argv[1] == "serve":
+ host = getenv("PASTE_HOST", "localhost")
+ port = int(getenv("PASTE_PORT", "8080"))
+ httpd = make_server(host, port, application)
+ httpd.serve_forever()
+ elif len(argv) == 2:
+ if argv[1] == "new-token":
+ with connect() as conn:
+ print(b64encode(generate_token(conn)).decode())
+ elif argv[1] == "list-tokens":
+ with connect() as conn:
+ for token_hash, created_at in get_tokens(conn):
+ created_at = datetime.fromtimestamp(created_at, timezone.utc)
+ print(f"{token_hash.hex()}\t{created_at.ctime()}")
+ elif argv[1] == "-h" or argv[1] == "--help":
+ print_usage()
+ print_help()
+ else:
+ print_usage()
+ exit(1)
+ elif len(argv) == 3:
+ if argv[1] == "delete-token":
+ token = argv[2]
+ with connect() as conn:
+ try:
+ try:
+ delete_token(conn, b64decode(token))
+ except ValueError:
+ delete_token_hash(conn, bytes.fromhex(token))
+ except ValueError:
+ print("Malformed token", file=stderr)
+ exit(1)
+ except KeyError:
+ print("Token not found", file=stderr)
+ exit(1)
+ elif argv[1] == "verify-token":
+ with connect() as conn:
+ try:
+ if not check_token(conn, b64decode(argv[2])):
+ print("Token not found", file=stderr)
+ exit(1)
+ print("Found")
+ except ValueError:
+ print("Malformed token", file=stderr)
+
+
+main()
diff --git a/paste/db.py b/paste/db.py
new file mode 100644
index 0000000..3de42fb
--- /dev/null
+++ b/paste/db.py
@@ -0,0 +1,69 @@
+from contextlib import contextmanager
+from hashlib import sha256
+from itertools import count
+from collections.abc import Iterator
+from typing import Union
+import sqlite3
+
+migrations = [
+ """CREATE TABLE file (
+ hash BLOB UNIQUE GENERATED ALWAYS AS (sha256(content)) STORED NOT NULL,
+ content BLOB NOT NULL,
+ created_at INTEGER DEFAULT (unixepoch('now'))
+) STRICT;
+
+CREATE UNIQUE INDEX file_hash_ix ON file ( hash );
+
+CREATE TABLE link (
+ name_hash BLOB UNIQUE GENERATED ALWAYS AS (sha256(name)) STORED NOT NULL,
+ name TEXT NOT NULL,
+ content_type TEXT NOT NULL DEFAULT "text/plain",
+ file_hash BLOB NOT NULL,
+ created_at INTEGER DEFAULT (unixepoch('now')),
+ FOREIGN KEY(file_hash) REFERENCES file(hash)
+) STRICT;
+
+CREATE UNIQUE INDEX link_name_hash_ix ON link ( name_hash );
+
+CREATE TABLE token (
+ hash BLOB PRIMARY KEY,
+ created_at INTEGER DEFAULT (unixepoch('now'))
+) STRICT, WITHOUT ROWID;""",
+]
+
+
+def _sha256_udf(b: Union[bytes, str]) -> bytes:
+ if isinstance(b, str):
+ b = b.encode()
+ return sha256(b).digest()
+
+
+@contextmanager
+def connect(
+ database: str, migrations: list[str] = migrations, **kwargs
+) -> Iterator[sqlite3.Connection]:
+ conn = sqlite3.connect(database, **kwargs)
+ conn.execute("PRAGMA foreign_keys = ON")
+ conn.execute("PRAGMA journal_mode = WAL")
+ conn.row_factory = sqlite3.Row
+ conn.create_function(name="sha256", narg=1, func=_sha256_udf, deterministic=True)
+ (user_version,) = conn.execute("PRAGMA user_version").fetchone()
+ for i in count(user_version + 1):
+ if i - 1 >= len(migrations):
+ break
+ migration = migrations[i - 1]
+ try:
+ conn.executescript(
+ "BEGIN IMMEDIATE TRANSACTION;\n"
+ f"{migration}\n"
+ f"PRAGMA user_version = {i:d}"
+ )
+ except:
+ conn.execute("ROLLBACK TRANSACTION")
+ raise
+ else:
+ conn.execute("COMMIT TRANSACTION")
+ try:
+ yield conn
+ finally:
+ conn.close()
diff --git a/paste/store.py b/paste/store.py
new file mode 100644
index 0000000..bae578f
--- /dev/null
+++ b/paste/store.py
@@ -0,0 +1,52 @@
+from sqlite3 import Connection
+
+
+def put(conn: Connection, name: str, content: bytes, content_type: str):
+ with conn:
+ conn.execute(
+ "INSERT OR IGNORE INTO file (content) VALUES (?)",
+ (content,),
+ )
+ conn.execute(
+ """
+ INSERT INTO link (
+ name, content_type, file_hash
+ ) VALUES (?, ?, sha256(?))
+ ON CONFLICT DO UPDATE
+ SET
+ content_type = excluded.content_type,
+ file_hash = excluded.file_hash""",
+ (name, content_type, content),
+ )
+
+
+def get(conn: Connection, name: str):
+ row = conn.execute(
+ """SELECT link.content_type, file.hash, file.content
+ FROM link
+ JOIN file ON file.hash = link.file_hash
+ WHERE name_hash = sha256(?)""",
+ (name,),
+ ).fetchone()
+ return row
+
+
+def head(conn: Connection, name: str):
+ row = conn.execute(
+ """SELECT link.content_type, file.hash, length(file.content),
+ CASE
+ WHEN link.content_type LIKE 'text/x.redirect%'
+ THEN file.content
+ ELSE NULL
+ END
+ FROM link
+ JOIN file ON file.hash = link.file_hash
+ WHERE name_hash = sha256(?)""",
+ (name,),
+ ).fetchone()
+ return row
+
+
+def delete(conn: Connection, name: str):
+ with conn:
+ conn.execute("DELETE FROM link WHERE name_hash = sha256(?)", (name,))
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..1c97d5b
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,221 @@
+# This file is automatically @generated by Poetry and should not be changed by hand.
+
+[[package]]
+name = "black"
+version = "23.1.0"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"},
+ {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"},
+ {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"},
+ {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"},
+ {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"},
+ {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"},
+ {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"},
+ {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"},
+ {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"},
+ {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"},
+ {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"},
+ {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"},
+ {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"},
+ {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"},
+ {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"},
+ {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"},
+ {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"},
+ {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"},
+ {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"},
+ {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"},
+ {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"},
+ {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"},
+ {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"},
+ {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"},
+ {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "click"
+version = "8.1.3"
+description = "Composable command line interface toolkit"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+ {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.7.0"
+description = "Node.js virtual environment builder"
+category = "dev"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
+files = [
+ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
+ {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},
+]
+
+[package.dependencies]
+setuptools = "*"
+
+[[package]]
+name = "packaging"
+version = "23.0"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
+ {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.11.0"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
+ {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
+]
+
+[[package]]
+name = "platformdirs"
+version = "3.0.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"},
+ {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"},
+]
+
+[package.extras]
+docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
+
+[[package]]
+name = "pyright"
+version = "1.1.293"
+description = "Command line wrapper for pyright"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyright-1.1.293-py3-none-any.whl", hash = "sha256:afc05309e775a9869c864da4e8c0c7a3e3be9d8fe202e780c3bae981bbb13936"},
+ {file = "pyright-1.1.293.tar.gz", hash = "sha256:9397fdfcbc684fe5b87abbf9c27f540fe3b8d75999a5f187519cae1d065be38c"},
+]
+
+[package.dependencies]
+nodeenv = ">=1.6.0"
+
+[package.extras]
+all = ["twine (>=3.4.1)"]
+dev = ["twine (>=3.4.1)"]
+
+[[package]]
+name = "setuptools"
+version = "67.2.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"},
+ {file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.4.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
+ {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
+]
+
+[[package]]
+name = "vermin"
+version = "1.5.1"
+description = "Concurrently detect the minimum Python versions needed to run code"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+files = [
+ {file = "vermin-1.5.1-py2.py3-none-any.whl", hash = "sha256:420995de564ac0c31e2157220259d7ac82556e8fa69c112d8005b78c14b0caf5"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.9"
+content-hash = "38150922c883e955841f915d7e837a0b8cf68878210c1013089e34c58dee3441"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ffa45fe
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[tool.poetry]
+name = "paste"
+version = "0.1.0"
+description = "A simple WSGI paste site"
+authors = ["Tomasz Kramkowski <tomasz@kramkow.ski>"]
+license = "MIT"
+readme = "README"
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+[tool.poetry.group.dev.dependencies]
+black = "^23.1.0"
+vermin = "^1.5.1"
+pyright = "^1.1.0"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/vermin b/vermin
new file mode 100755
index 0000000..ccccb45
--- /dev/null
+++ b/vermin
@@ -0,0 +1,2 @@
+#!/bin/sh
+poetry run vermin --no-tips --target=3.9 --violations .