this website is hosted on bluesky (for real this time)

social.vodka, atproto, and decentralized websites

(march 23, 2025)

~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


clearly, this could be improved.

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 unwieldiness of the previous URLs is not an extremely hard problem. rkeys support a number of characters (the slash is not one of them) and since i was not particularly interested in a proper appview which aggregated filenames in records, they would work fine for this. of all characters supported in an rkey, the most notable one was the ":". it is not supported as a filename in most filesystems (what's ext4?), and would therefore work fine as a replacement separator character in the rkey.


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.


current issues

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:

  1. while inconvenient, colons still exist in filenames. this could be fixed by specifying the intended filename and/or path of a blob in the record (which are just single-blob wrappers at the moment).
  2. if an href refers to a path which starts with a "/", it will try to go to the root (e.g. social.vodka/file.html rather than social.vodka/joe.bsky.social/file.html). this means that hrefs and srcs and whatnot must have the leading slash stripped.
  3. uploading files is sort of inconvenient - this is still a proof of concept
  4. a lot of data (mostly mimetype related stuff) needs to get added along - i'd imagine this would go in the record, but haven't put much thought to it.

where neocities.org stands to improve this

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.

  1. (filenames) this isn't a neocities-specific thing, just a problem fixable by anyone who can put more time into this.
  2. (root paths) this is the special thing - neocities already has some level of atproto integration. linking a site (john.neocities.org) to an atproto handle means that root paths would no longer be a concern as each person has their own subdomain. (this was tried with social.vodka, but it was quickly discovered that multi-level wildcard subdomains do not exist :( )
  3. (file UI) - neocities already has a nice file management UI, so that's all good and great
  4. (extra metadata) basically 1. someone with more time can do this

how do i upload files?

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}")