From 3cbff4c68e5a66f03213984610f79d59f3681d34 Mon Sep 17 00:00:00 2001 From: ducklet Date: Sat, 3 Feb 2024 15:12:35 +0100 Subject: [PATCH] init --- .gitignore | 1 + Dockerfile | 14 ++++++ README.md | 43 ++++++++++++++++ poetry.lock | 119 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 15 ++++++ scripts/build | 16 ++++++ scripts/run | 12 +++++ scripts/server | 10 ++++ webclip/__init__.py | 1 + webclip/config.py | 3 ++ webclip/server.py | 110 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 344 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100755 scripts/build create mode 100755 scripts/run create mode 100755 scripts/server create mode 100644 webclip/__init__.py create mode 100644 webclip/config.py create mode 100644 webclip/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7558f39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM docker.io/library/python:3-alpine + +# RUN --mount=type=bind,source=build/requirements.txt,target=/var/local/requirements.txt \ +COPY build/requirements.txt /var/local/requirements.txt +RUN \ + pip install --upgrade pip && \ + pip install -r /var/local/requirements.txt + +COPY scripts/server /var/local/ +COPY webclip /var/local/webclip + +WORKDIR /var/local/ + +CMD ["./server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c00f2c9 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +A simple HTTP service to store & retrieve ephemeral data. + +The application can be used to `POST`, `PUT`, and `GET` a limited amount of data. +Old data will automatically be removed. +You may choose to have data immediately removed once it's been retrieved. + +This can be used as a simple clipboard on the web. + +# Examples + +Create a password on your computer and share it with your mobile device. + +```sh +pwgen -s 128 1 \ + | tee my-new-password \ + | https post webclip.example.org/?once=1 \ + | jq -r .url \ + | qrencode -tUTF8 +``` + + +Share a clipboard between multiple devices. + +```sh +# Choose a unique name for your clipboard. +WEBCLIP=my-private-clipboard + +# Copy something on your computer, make it available to your web clipboard. +xclip -o -sel clip | https put "webclip.example.org/$WEBCLIP" + +# Load something from your web clipboard. +https "webclip.example.org/$WEBCLIP" | xclip -sel clip +``` + +# Feature ideas + +Some of these ideas might be bad, as they could impact performance or security negatively, or just aren't useful. + +- Support setting the response content-type to allow sharing of rich media files +- Support query param `new=1` for `POST` & `PUT` to ensure no existing data is replaced +- Encrypt data in memory/storage +- Support long-polling for updates, effectively claiming a clipboard for `once` ops before it's created, `http "${url}?wait=1" &; =7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +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 = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.36.1" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.36.1-py3-none-any.whl", hash = "sha256:d5b43a72f475fd1b9707f661aa66da42d59ae16c9b2a5845b4edee4309c425ee"}, + {file = "starlette-0.36.1.tar.gz", hash = "sha256:96df8541093dfd37624b5bf980802b99750db6718dd3ca341618fbbcdd6136fb"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "uvicorn" +version = "0.27.0.post1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"}, + {file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "3c4cf8dae47abe53b87029fdd6d44aa194819b75d0c3c29a6b4518bca5701182" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3a1d19 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "webclip" +version = "0" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.12" +uvicorn = "^0.27.0.post1" +starlette = "^0.36.1" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..af26b80 --- /dev/null +++ b/scripts/build @@ -0,0 +1,16 @@ +#!/bin/sh -eu + +: "${DOCKER_BIN:=docker}" + +here=$(dirname "$(realpath "$0" || echo "$0")") +root=$(dirname "$here") + +builddir=build + +[ -z "${DEBUG:-}" ] || set -x + +cd "$root" + +[ -d "$builddir" ] || mkdir "$builddir" +poetry export --output="$builddir"/requirements.txt +$DOCKER_BIN build --tag webclip . diff --git a/scripts/run b/scripts/run new file mode 100755 index 0000000..360434c --- /dev/null +++ b/scripts/run @@ -0,0 +1,12 @@ +#!/bin/sh -eu + +: "${DOCKER_BIN:=docker}" + +[ -z "${DEBUG:-}" ] || set -x + +exec $DOCKER_BIN run \ + --rm -it \ + --env UVICORN_HOST=0.0.0.0 \ + --publish 8000:8000 \ + webclip \ + "$@" diff --git a/scripts/server b/scripts/server new file mode 100755 index 0000000..ce66965 --- /dev/null +++ b/scripts/server @@ -0,0 +1,10 @@ +#!/bin/sh -eu + +[ -z "${DEBUG:-}" ] || set -x + + # --reload \ +# exec poetry run uvicorn \ +exec uvicorn \ + --no-server-header \ + --no-date-header \ + webclip:app diff --git a/webclip/__init__.py b/webclip/__init__.py new file mode 100644 index 0000000..ff892ce --- /dev/null +++ b/webclip/__init__.py @@ -0,0 +1 @@ +from .server import app diff --git a/webclip/config.py b/webclip/config.py new file mode 100644 index 0000000..f3a12ae --- /dev/null +++ b/webclip/config.py @@ -0,0 +1,3 @@ +debug = False +max_clip_size = 1024 +max_clips = 10 diff --git a/webclip/server.py b/webclip/server.py new file mode 100644 index 0000000..c4b6752 --- /dev/null +++ b/webclip/server.py @@ -0,0 +1,110 @@ +import secrets + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from . import config + +_storage: dict[str, tuple[bool, bytes]] = {} + + +def cull(d: dict, /, size: int) -> int: + """Remove the oldest items from dict until it fits the size. + + Return the number of items removed.""" + over = len(d) - size + if over <= 0: + return 0 + + keys = iter(d.keys()) + for _ in range(over): + oldest_key = next(keys) + del d[oldest_key] + + return over + + +def store_data(key: str, data: bytes, once: bool) -> None: + # Make room for another clip. + if key not in _storage: + cull(_storage, config.max_clips - 1) + + _storage[key] = (once, data) + + +def load_data(key: str) -> bytes | None: + clip = _storage.get(key) + if clip is None: + return None + once, data = clip + if once: + del _storage[key] + return data + + +async def read_bytes(request: Request, max_len: int = 1024) -> bytes: + chunks: list[bytes] = [] + read_len = 0 + async for chunk in request.stream(): + chunks.append(chunk) + read_len += len(chunk) + if read_len >= max_len: + break + body = b"".join(chunks) + return body[:max_len] + + +async def get_clip(request: Request) -> Response: + key = request.path_params["name"] + + if (body := load_data(key)) is None: + return Response(status_code=404) + + return Response(body, status_code=200) + + +async def put_clip(request: Request) -> Response: + key = request.path_params["name"] + once = request.query_params.get("once") == "1" + + body = await read_bytes(request, max_len=config.max_clip_size) + store_data(key, body, once) + + return Response(status_code=204) + + +async def post_clip(request: Request) -> Response: + once = request.query_params.get("once") == "1" + + body = await read_bytes(request, max_len=config.max_clip_size) + + key = secrets.token_urlsafe() + store_data(key, body, once) + + url = request.url_for("clip", name=key) + + # return RedirectResponse(url, status_code=303) + return JSONResponse({"url": str(url)}, status_code=201) + + +async def clip(request: Request) -> Response: + + match request.method: + + case "GET": + return await get_clip(request) + case "PUT": + return await put_clip(request) + + return Response(status_code=405) + + +app = Starlette( + debug=config.debug, + routes=[ + Route("/{name}", clip, methods=["GET", "PUT"], name="clip"), + Route("/", post_clip, methods=["POST"]), + ], +)