Skip to content

Commit 141953b

Browse files
feat(server): working
Signed-off-by: John Andersen <johnandersen777@protonmail.com>
0 parents  commit 141953b

File tree

10 files changed

+286
-0
lines changed

10 files changed

+286
-0
lines changed

.github/workflows/release.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
- "v*"
8+
9+
jobs:
10+
pypi-publish:
11+
name: upload release to PyPI
12+
runs-on: ubuntu-latest
13+
# Specifying a GitHub environment is optional, but strongly encouraged
14+
environment: pypi
15+
permissions:
16+
# IMPORTANT: this permission is mandatory for trusted publishing
17+
id-token: write
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-python@v4
22+
with:
23+
python-version: "3.x"
24+
25+
- name: deps
26+
run: python -m pip install -U build
27+
28+
- name: build
29+
run: python -m build
30+
31+
- name: Publish package distributions to PyPI
32+
uses: pypa/gh-action-pypi-publish@release/v1
33+
with:
34+
verify-metadata: false

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

LICENSE

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
This is free and unencumbered software released into the public domain.
2+
Anyone is free to copy, modify, publish, use, compile, sell, or
3+
distribute this software, either in source code form or as a compiled
4+
binary, for any purpose, commercial or non-commercial, and by any
5+
means.
6+
7+
In jurisdictions that recognize copyright laws, the author or authors
8+
of this software dedicate any and all copyright interest in the
9+
software to the public domain. We make this dedication for the benefit
10+
of the public at large and to the detriment of our heirs and
11+
successors. We intend this dedication to be an overt act of
12+
relinquishment in perpetuity of all present and future rights to this
13+
software under copyright law.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
20+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
21+
OTHER DEALINGS IN THE SOFTWARE.
22+
23+
For more information, please refer to <http://unlicense.org/>

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# ATProto based pastebin
2+
3+
Paste and retrive
4+
5+
```bash
6+
curl -sf http://localhost:8000/$(curl -X POST --data-binary @src/atprotobin/cli.py -H "Content-Type: text/plain" http://localhost:8000/ | tee /dev/stderr | jq -r .id)
7+
```
8+
9+
Start server
10+
11+
```bash
12+
ATPROTO_BASE_URL=https://atproto.chadig.com ATPROTO_HANDLE=publicdomainrelay.atproto.chadig.com ATPROTO_PASSWORD=$(python -m keyring get publicdomainrelay@protonmail.com password.publicdomainrelay.atproto.chadig.com) python -m atprotobin
13+
```
14+
15+
- References
16+
- https://bsky.app/profile/johnandersen777.bsky.social/post/3lc47yvadu22i
17+
18+
[![asciicast](https://asciinema.org/a/693001.svg)](https://asciinema.org/a/693001)

pyproject.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[project]
2+
name = "atprotobin"
3+
version = "0.1.0"
4+
description = "ATProto based pastebin"
5+
readme = {file = "README.md", content-type = "text/markdown"}
6+
authors = [
7+
{ name = "Public Domain", email = "publicdomainrelay@protonmail.com" }
8+
]
9+
license = {text = "Unlicense"}
10+
requires-python = ">=3.12"
11+
dependencies = [
12+
"atproto>=0.0.55",
13+
"uvicorn[standard]>=0.32.1",
14+
"fastapi[standard]>=0.115.5",
15+
"pillow>=11.0.0",
16+
"python-magic>=0.4.27",
17+
"python-multipart>=0.0.19",
18+
]
19+
20+
[project.urls]
21+
Repository = "https://github.com/publicdomainrelay/atprotobin.git"
22+
Issues = "https://github.com/publicdomainrelay/atprotobin/issues"
23+
24+
[project.scripts]
25+
atprotobin = "atprotobin.cli:main"
26+
27+
[build-system]
28+
requires = ["hatchling"]
29+
build-backend = "hatchling.build"

src/atprotobin/__init__.py

Whitespace-only changes.

src/atprotobin/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .cli import main
2+
3+
if __name__ == "__main__":
4+
main()

src/atprotobin/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
import asyncio
3+
4+
import uvicorn
5+
6+
async def async_main():
7+
config = uvicorn.Config(
8+
"atprotobin.server:app",
9+
port=int(os.environ.get("PORT", "8000")),
10+
log_level="debug",
11+
)
12+
server = uvicorn.Server(config)
13+
await server.serve()
14+
15+
def main() -> None:
16+
asyncio.run(async_main())

src/atprotobin/server.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import os
2+
import hashlib
3+
import contextlib
4+
from typing import Annotated
5+
6+
from atproto import AsyncClient, models
7+
from fastapi import FastAPI, File, UploadFile, Request, Response
8+
9+
from .zip_image import encode, decode
10+
11+
app = FastAPI()
12+
13+
hash_alg = os.environ.get("HASH_ALG", "sha256")
14+
atproto_base_url = os.environ["ATPROTO_BASE_URL"]
15+
atproto_handle = os.environ["ATPROTO_HANDLE"]
16+
atproto_password= os.environ["ATPROTO_PASSWORD"]
17+
18+
did_plcs = {}
19+
client = AsyncClient(
20+
base_url=atproto_base_url,
21+
)
22+
23+
@contextlib.asynccontextmanager
24+
async def lifespan(app: FastAPI):
25+
profile = await client.login(
26+
atproto_handle,
27+
atproto_password,
28+
)
29+
did_plcs[atproto_handle] = profile.did
30+
yield
31+
32+
app = FastAPI(lifespan=lifespan)
33+
34+
@app.post("/")
35+
async def create(request: Request):
36+
file_data = await request.body()
37+
hash_instance = hashlib.new(hash_alg)
38+
hash_instance.update(file_data)
39+
data_as_image_hash = hash_instance.hexdigest()
40+
data_as_image_hash = f"{hash_alg}:{data_as_image_hash}"
41+
_mimetype, data_as_image = encode(file_data)
42+
post = await client.send_image(
43+
text=data_as_image_hash,
44+
image=data_as_image,
45+
image_alt=data_as_image_hash,
46+
)
47+
return {
48+
"id": post.uri.split("/")[-1],
49+
"url": f'https://bsky.app/profile/{atproto_handle}/post/{post.uri.split("/")[-1]}',
50+
"uri": post.uri,
51+
"cid": post.cid,
52+
}
53+
54+
@app.get("/{post_id}")
55+
async def get(post_id: str):
56+
post = await client.get_post(post_id)
57+
blob = await client.com.atproto.sync.get_blob(
58+
models.com.atproto.sync.get_blob.Params(
59+
cid=post.value.embed.images[0].image.ref.link,
60+
did=did_plcs[atproto_handle],
61+
),
62+
)
63+
mimetype, output_bytes = decode(blob)
64+
return Response(content=output_bytes, media_type=mimetype)

src/atprotobin/zip_image.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# cat ~/Documents/publicdomainrelay/gitatp/src/gitatp/update_profile.js | python -u src/federation_git/policy_image.py | tee policy_image.png | python -u src/federation_git/policy_image.py
2+
import sys
3+
import zipfile
4+
from io import BytesIO
5+
6+
import magic
7+
from PIL import Image, ImageDraw, ImageFont
8+
9+
# Create a zip archive containing the internal files
10+
def create_zip_of_files(file_contents, file_name: str = "manifest"):
11+
zip_buffer = BytesIO()
12+
mimetype = magic.from_buffer(file_contents, mime=True)
13+
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
14+
zipf.writestr(mimetype, file_contents)
15+
zip_buffer.seek(0)
16+
return mimetype, zip_buffer.read()
17+
18+
def create_png_with_zip(zip_data, text_content):
19+
"""
20+
Create a PNG image that contains rendered text and append zip data
21+
to create a polyglot PNG/zip file.
22+
23+
Args:
24+
zip_data (bytes): The binary data of the zip file.
25+
text_content (str): The text content to render inside the PNG.
26+
27+
Returns:
28+
bytes: The combined PNG and zip data.
29+
"""
30+
# Font configuration
31+
font_size = 14
32+
try:
33+
# Attempt to use a monospaced font
34+
font = ImageFont.truetype("DejaVuSansMono.ttf", font_size)
35+
except IOError:
36+
# Fallback to default font
37+
font = ImageFont.load_default()
38+
39+
# Calculate the size of the rendered text
40+
lines = text_content.split('\n')
41+
max_line_width = max(font.getbbox(line)[2] for line in lines) # Use getbbox()[2] for width
42+
line_height = font.getbbox("A")[3] # Use getbbox()[3] for height
43+
total_height = line_height * len(lines)
44+
45+
# Create an image with a white background
46+
img = Image.new('RGB', (max_line_width + 20, total_height + 20), color='white')
47+
draw = ImageDraw.Draw(img)
48+
49+
# Draw the text onto the image
50+
y_text = 10
51+
for line in lines:
52+
draw.text((10, y_text), line, font=font, fill='black')
53+
y_text += line_height
54+
55+
# Save the image to a BytesIO object
56+
img_buffer = BytesIO()
57+
img.save(img_buffer, format='PNG')
58+
img_data = img_buffer.getvalue()
59+
img_buffer.close()
60+
61+
# Combine the PNG image data and the zip data
62+
png_zip_data = img_data + zip_data
63+
64+
return png_zip_data
65+
66+
def encode(input_data):
67+
text_content = input_data.decode()
68+
69+
# Create zip archive of internal files
70+
mimetype, zip_data = create_zip_of_files(text_content)
71+
72+
# Create PNG with embedded zip and rendered text
73+
png_zip_data = create_png_with_zip(zip_data, text_content)
74+
75+
# Write out image
76+
return mimetype, png_zip_data
77+
78+
def decode(data):
79+
# Attempt to open the data as a zip file
80+
with zipfile.ZipFile(BytesIO(data)) as zipf:
81+
# Iterate through the files in the zip archive
82+
for file_info in zipf.infolist():
83+
with zipf.open(file_info) as file:
84+
# Output the contents of each file to stdout
85+
return file_info.orig_filename, file.read()
86+
87+
def main():
88+
input_data = sys.stdin.buffer.read()
89+
if input_data.startswith(b"\x89PNG"):
90+
mimetype, output_bytes = decode(input_data)
91+
else:
92+
mimetype, output_bytes = encode(input_data)
93+
print(f"{mimetype}", file=sys.stderr)
94+
sys.stdout.buffer.write(output_bytes)
95+
96+
if __name__ == "__main__":
97+
main()

0 commit comments

Comments
 (0)