init
This commit is contained in:
commit
3cbff4c68e
11 changed files with 344 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
build/
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -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"]
|
||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -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" &; <secret http put "${url}?once=1`
|
||||
- Support 302 redirects, `echo 'https://example.org/my/space' | https put "$url"; http --follow "$url"`
|
||||
119
poetry.lock
generated
Normal file
119
poetry.lock
generated
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.2.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"},
|
||||
{file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=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"
|
||||
15
pyproject.toml
Normal file
15
pyproject.toml
Normal file
|
|
@ -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"
|
||||
16
scripts/build
Executable file
16
scripts/build
Executable file
|
|
@ -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 .
|
||||
12
scripts/run
Executable file
12
scripts/run
Executable file
|
|
@ -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 \
|
||||
"$@"
|
||||
10
scripts/server
Executable file
10
scripts/server
Executable file
|
|
@ -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
|
||||
1
webclip/__init__.py
Normal file
1
webclip/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .server import app
|
||||
3
webclip/config.py
Normal file
3
webclip/config.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
debug = False
|
||||
max_clip_size = 1024
|
||||
max_clips = 10
|
||||
110
webclip/server.py
Normal file
110
webclip/server.py
Normal file
|
|
@ -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"]),
|
||||
],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue