# DeepSky A [Gemini](https://geminiprotocol.net/) server for [Bluesky](https://bsky.app) microblog posts. It reads from the public Bluesky AppView (`https://public.api.bsky.app`), so no account or authentication is needed. ## Generate a client certificate To be recognised as your Bluesky account while browsing (see [Identity](#identity-who-is-browsing) below), generate a P-256 client certificate and publish the binding record: ```sh deepsky identity \ -handle you.bsky.social \ -generate-cert \ -cert lagrange.crt \ -key lagrange.key \ -app-password ``` This writes `lagrange.crt` / `lagrange.key` — import that pair as an identity in your Gemini client (e.g. [Lagrange](https://gmi.skyjake.fi/lagrange/)). Drop `-app-password` to print the record JSON and publish it yourself. The certificate **must** use ECDSA P-256, which `-generate-cert` guarantees. ## Routes | Path | Shows | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `gemini://deepsky.byjp.me/p/{handle-or-did}` | Profile details and links to the lists below | | `gemini://deepsky.byjp.me/p/{handle-or-did}/posts` | The account's posts (replies and reposts excluded) | | `gemini://deepsky.byjp.me/p/{handle-or-did}/replies` | The account's replies | | `gemini://deepsky.byjp.me/p/{handle-or-did}/reposts` | Posts the account has reposted | | `gemini://deepsky.byjp.me/p/{handle-or-did}/likes` | Posts the account has liked | | `gemini://deepsky.byjp.me/p/{handle-or-did}/publications` | The account's [standard.site](https://standard.site/) publications, each previewing its newest documents | | `gemini://deepsky.byjp.me/p/{handle-or-did}/publications/{pub-id}/` | A single publication and its documents (paginated) | | `gemini://deepsky.byjp.me/p/{handle-or-did}/p/{tid}/` | A single post | | `gemini://deepsky.byjp.me/p/{handle-or-did}/documents/{doc-id}/` | A single `site.standard.document` | | `gemini://deepsky.byjp.me/p/{handle-or-did}/avatar` | The account's avatar image (proxied) | | `gemini://deepsky.byjp.me/me` | The atproto account your client certificate is linked to (if any) | `{handle-or-did}` is a Bluesky handle (e.g. `bsky.app`) or DID (e.g. `did:plc:z72i7hdynmk6r22z27h6tvur`). `{tid}` is a post's record key — the last segment of its `at://` URI. The list pages are paginated ~10 at a time; a `=> …?cursor=… More →` link at the foot of a page fetches the next one. Posts render as gemtext: the account's own text as plain paragraphs (quote blocks are reserved for the text of quoted posts), external link cards and quoted posts as `=>` links, and each image as an `=> img-N.webp 🖼️ ` link. Those image links are **proxied through this server** from the Bluesky CDN (the smaller, size-limited variant — full colour WebP) so Gemini clients that render inline images can show them without reaching out to the CDN directly. ### Publications & documents [standard.site](https://standard.site/) is a set of atproto lexicons for publishing. `/publications` lists an account's `site.standard.publication` records, previewing each publication's newest `site.standard.document` records (with a count of any others), followed by an "Other documents" section for documents not tied to a known publication, then any publications with no documents. Each publication has a "View more" link to its own page — `/publications/{pub-id}/` — which lists that publication's documents, paginated newest-first. Document bodies are stored in platform-specific shapes. The renderer handles **leaflet.pub** (`pub.leaflet.content`), **pckt.blog** (`blog.pckt.content`), **offprint.app** (`app.offprint.content`), and older **raw-markdown** documents (`textContent`), via a single walker keyed on each block's `$type` — so an unfamiliar platform still degrades to readable text. Headings, lists, code, block quotes and links are rendered as gemtext; images embedded as PDS blobs are proxied through this server (like post images) and linked as `img-N.`. These records live in the account's repository, not the AppView, so they are read from the account's PDS (resolved from its DID document). Some PDS implementations emit user content with invalid JSON escapes; the client repairs these before decoding. ### Likes The public AppView does not expose likes for arbitrary accounts (`app.bsky.feed.getActorLikes` requires authentication), so `/likes` is read straight from the account's repository: the DID document is resolved to find the PDS, its `app.bsky.feed.like` records are listed, and the referenced posts are hydrated via the AppView. ## Running ```sh go build -o deepsky . ./deepsky ``` Gemini requires TLS. On first run, if the certificate and key files don't exist, a self-signed keypair (trust-on-first-use, as Gemini expects) is generated and written to `server.crt` / `server.key` and reused thereafter. Provide real certificates (e.g. Let's Encrypt) by placing them at those paths. Press Ctrl-C to stop: the server stops accepting connections, gives in-flight requests a moment to finish, and exits cleanly. A second Ctrl-C forces an immediate quit. ### `serve` flags | Flag | Default | Description | | ------------ | ------------------------------------------------- | -------------------------------------------------------- | | `-addr` | `:1965` | Address to listen on | | `-domain` | `deepsky.byjp.me` | Server name (TLS SNI) to serve | | `-cert` | `server.crt` | TLS certificate file (generated if absent) | | `-key` | `server.key` | TLS private key file (generated if absent) | | `-appview` | `https://public.api.bsky.app` | Bluesky AppView base URL | | `-index` | `identity.db` | Identity index database (empty to disable) | | `-jetstream` | `wss://jetstream2.us-east.bsky.network/subscribe` | Jetstream endpoint for live identity records | | `-lightrail` | `https://lightrail.microcosm.blue` | lightrail host for the identity backfill (empty to skip) | `deepsky` (no subcommand) and `deepsky serve` are equivalent. The handler is also served under `localhost`, so for local development you can point a Gemini client at `gemini://localhost/p/bsky.app`. ## Identity: who is browsing? Gemini authenticates clients with TLS **client certificates** — a stable but anonymous key. DeepSky can recognise the Bluesky account behind a certificate via a custom lexicon, [`me.byjp.deepsky.identity`](internal/identity). A record at `at:///me.byjp.deepsky.identity/` is a [badge.blue](https://badge.blue)-style signed statement that the holder of the certificate's private key is that account. The record is signed by the certificate's own private key and bound to the repository DID, so it cannot be copied into another account's repo. The server only ever **verifies**; you create the record once with the `identity` command: ```sh # Generate a P-256 client cert and publish the binding via an app password: deepsky identity -handle you.bsky.social -generate-cert -app-password # …or print the record JSON to publish yourself (no credentials needed): deepsky identity -handle you.bsky.social -cert your.crt -key your.key ``` The server learns about records from a one-time [lightrail](https://lightrail.microcosm.blue) backfill plus a live Jetstream tail, both verifying signatures before indexing. Once linked, visit `gemini://deepsky.byjp.me/me` with your certificate to see yourself recognised. Certificates must use **ECDSA P-256** (Bluesky's signing curve); browsing without a certificate stays fully anonymous. ## Layout - `main.go` — subcommand dispatch (`serve` / `identity`) - `serve.go` — TLS setup, identity index wiring, server startup, graceful shutdown - `identity_cmd.go` — the `identity` minting command - `internal/bsky` — client and types for the Bluesky AppView and PDS records (read-only except `write.go`, used only by `identity`) - `internal/doc` — parser for site.standard.document content into renderable blocks - `internal/render` — gemtext rendering of profiles, posts, publications and documents - `internal/server` — Gemini route handlers and the image/blob proxies ([a-h/gemini](https://github.com/a-h/gemini)) - `internal/identity` — certificate fingerprinting and badge.blue record signing/verification - `internal/identity/index` — verified fingerprint→DID index (bbolt) fed by Jetstream + lightrail - `internal/tlsx` — load-or-generate the server keypair and client certificates ## Acknowledgements Built on [a-h/gemini](https://github.com/a-h/gemini).