~a few months ago, I saw
this post by Daniel Mangum
on serving HTML through the atproto PDS blob system. it only served a single file
and relied on the PDS providing the mime type as given when the file was uploaded;
this would also not work for pages that linked to each other because the CID is a
direct hash of the file (therefore necessitating a cyclical loop of updating the
links in each page).
not all that much of a website, is it
the main issues with serving directly from a pds were:
i decided that the simplest solution
(the one i implemented & will outline in this post)
was to provide a thin "appview" layer - hardly even an appview, more of a
request translator than anything else.
sorry it's shifted to the left, i'm bad at CSS
the current "appview" is as follows:
from flask import Flask, request, Response, send_file
import httpx
from atproto import Client, DidDocument, IdResolver, DidInMemoryCache
from atproto.exceptions import BadRequestError, DidNotFoundError
from mimetypes import guess_type
app = Flask(__name__)
client = Client()
cache = DidInMemoryCache()
resolver = IdResolver(cache=cache)
collection = "vodka.social.website.file"
class RecordNotFound(BaseException):
def __init__(self, recordName: str):
self._recordName = recordName
def recordName(self) -> str:
return self._recordName
class File:
def __init__(self, filepath: str, content: any, mimeType: str):
self._filepath = filepath
self._content = content
self._mimeType = mimeType
def filepath(self) -> str:
return self.filepath
def content(self) -> any:
return self._content
def mimeType(self) -> str:
return self._mimeType
def __str__(self) -> str:
return f"File with name {self._filepath} of type {self._mimeType} and content {self._content}"
def get_file(did: str, filepath: str) -> File:
try:
record = client.com.atproto.repo.get_record(
{
"repo": did,
"collection": collection,
# strips leading slash (e.g. changes /path/to/file.html to path:to:file.html)
"rkey": filepath.lstrip("/").replace("/", ":"),
}
)
except BadRequestError as e:
if e.response.content.error == "RecordNotFound":
raise RecordNotFound(filepath)
blob = client.com.atproto.sync.get_blob(
{"did": did, "cid": record["value"]["file"]["ref"]["$link"]}
)
try:
file = File(
filepath=filepath,
content=blob,
mimeType=record["value"]["mimeType"],
)
return file
except TypeError:
print("invalid file tried to get")
@app.route("/favicon.ico", methods=["GET"])
def favicon():
return send_file("cv2out.png")
@app.route("/<string:handle>/", methods=["GET"])
def index(handle):
try:
did = resolver.handle.ensure_resolve(handle)
except DidNotFoundError:
return f"Handle '{handle}' does not exist", 404
try:
file = get_file(did=did, filepath="index.html")
except RecordNotFound as rnf:
return f"User {handle} does not have file at {rnf.recordName()}", 404
return Response(file.content(), mimetype=file.mimeType())
@app.route("/<string:handle>/<path:path>", methods=["GET"])
def arbitrary_file(handle, path):
try:
did = resolver.handle.ensure_resolve(handle)
except DidNotFoundError:
return f"Handle '{handle}' does not exist", 404
try:
file = get_file(did=did, filepath=path)
except RecordNotFound as rnf:
return f"User {handle} does not have file at {rnf.recordName()}", 404
return Response(file.content(), mimetype=file.mimeType())
if __name__ == "__main__":
app.run(host="0.0.0.0", port="2020")
it's fine. it does the job (not hitting any KPIs, though). it's running with gunicorn right now in a systemd service behind the most basic nginx config you can imagine, though if you're looking to replicate this yourself i'd recommend rewriting it to be less of a hack.
now, we have new issues! rather than unwieldy URLs, we have slightly less unwieldy URLs and the possibility for normal files to be uploaded without much concern around making them interlink. so, a list of the issues is as follows:
sort of a non-sequitur if you weren't there at the atmosphere conference, but i'm going to at least assume that you (the reader) know of neocities. let's cover how it would improve/fix each of the current problems in the list.
glad you asked! it's "just" a python file you run and it uploads the contents
of a directory to your PDS. (social.vodka fetches index.html by default)
below is upload-directory.py
, which is all you need (along
with the python library atproto
).
to use:
make a directory called upload-dir/
. all files in this directory will
be uploaded to your repo in that structure (e.g. something at upload-dir/abc/def.html
will appear at social.vodka/.../abc/def.html). then, make a .env file with USERNAME
(which is actually your handle) and APP_PASSWORD (which is an app password). it tells
you where to find the pages/files once they are uploaded.
import os
from typing import Optional
from atproto import Client, Session, SessionEvent
from atproto_client import models
import httpx
from dotenv import load_dotenv
from mimetypes import guess_type
load_dotenv()
path = "upload-dir/"
current_path = os.getcwd()
initial_scan = os.scandir(path)
direntries = set()
queue = set()
def process_item(item):
if item.is_dir():
queue.add(item)
elif item.is_file():
direntries.add(item)
for item in initial_scan:
process_item(item)
while len(queue) != 0:
item = queue.pop()
if item.is_dir():
for subitem in os.scandir(item.path):
process_item(subitem)
print([e for e in direntries])
# copy pasted login code start
def get_session() -> Optional[str]:
try:
with open('session.txt') as f:
return f.read()
except FileNotFoundError:
return None
def save_session(session_string: str) -> None:
with open('session.txt', 'w') as f:
f.write(session_string)
def on_session_change(event: SessionEvent, session: Session) -> None:
print('Session changed:', event, repr(session))
if event in (SessionEvent.CREATE, SessionEvent.REFRESH):
print('Saving changed session')
save_session(session.export())
def init_client() -> Client:
client = Client()
client.on_session_change(on_session_change)
session_string = get_session()
if session_string:
print('Reusing session')
client.login(session_string=session_string)
else:
print('Creating new session')
client.login(os.getenv("USERNAME"), os.getenv("APP_PASSWORD"))
return client
# copy pasted login code end
client = init_client()
pds = client._session.pds_endpoint
print("logged in")
for file in direntries:
frelpath = file.path[len(path):]
print(f"****** reading {frelpath} ******")
if ":" in frelpath:
print(f"WARNING: COLON IN FILEPATH {{{frelpath}}} - THIS WILL CAUSE PROBLEMS")
sanitized_path = frelpath.lstrip("/").replace("/", ":")
mimetype = guess_type(frelpath)
# if "text/" in mimetype[0]: # needs to have the slash
# f = open(path + frelpath, "r")
# else:
# f = open(path + frelpath, "rb")
f = open(path + frelpath, "rb")
d = f.read()
f.close()
# delete previous record
client.com.atproto.repo.delete_record(data=models.ComAtprotoRepoDeleteRecord.Data(
collection="vodka.social.website.file",
repo=os.getenv("USERNAME"),
rkey=sanitized_path
))
print(f"guessed mimetype: {mimetype}")
print(f"length/size: {str(len(d))}")
# upload blob
# can't use the library - see https://github.com/MarshalX/atproto/issues/578
blobref = client.com.atproto.repo.upload_blob(data=d).blob
# create record
endpoint = "/xrpc/com.atproto.repo.createRecord"
recordres = httpx.post(url=pds + endpoint, json={
"repo": client._session.handle,
"collection": "vodka.social.website.file",
"rkey": sanitized_path,
"record": {
"$type": "vodka.social.website.file",
"file": {
"$type": "blob",
"ref": {
"$link": blobref.ref.link
},
"mimeType": blobref.mime_type,
"size": blobref.size
},
"mimeType": mimetype[0]
}
}, headers={"authorization": "Bearer " + client._session.access_jwt})
print(recordres.json())
print(f"item available at https://social.vodka/{os.getenv("USERNAME")}/{frelpath}")