Keep atproto PDS and Gravatar profile picture in sync
  • TypeScript 96.9%
  • Dockerfile 3.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
alex 24fd8353f6
All checks were successful
Build and Push Container Image / build-and-push-1 (push) Successful in 48s
Build and Push Container Image / build-and-push (push) Successful in 0s
ci: use reusable workflow
Signed-off-by: alex <alex@noreply.forge.l3x.in>
2026-07-14 11:59:55 +00:00
.forgejo/workflows ci: use reusable workflow 2026-07-14 11:59:55 +00:00
.zed feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
lib feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
.env.example feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
.gitignore feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
Containerfile fix: final cleanups 2026-07-10 19:31:46 +03:00
deno.json fix: final cleanups 2026-07-10 19:31:46 +03:00
LICENSE feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
login.ts feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
main.ts feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
main_test.ts feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
README.md docs: update readme 2026-07-11 16:02:21 +00:00
renovate.json Initial commit 2026-07-09 18:52:27 +00:00

atproto-gravatar-sync

Keeps an account's Gravatar profile picture in sync with its Bluesky / ATProto avatar. It runs once per invocation — schedule it with a systemd timer / cronjob.

Each run:

  1. Validates the token by reading the profile for the account email (GET /profiles/{SHA-256(email)}). Fails fast on a bad token or mismatched email.
  2. Resolves the handle → DID via the PDS (com.atproto.identity.resolveHandle).
  3. Reads the profile record (app.bsky.actor.profile) → avatar blob cid + mime type.
  4. Skips if unchanged — the cid is content-addressed, so it changes only when the bytes do. Gravatar is touched only when the avatar actually changed.
  5. Fetches the blob (com.atproto.sync.getBlob) and uploads it to Gravatar, atomically selecting it for the account email (POST /me/avatars?selected_email_hash={SHA-256(email)}).
  6. Cleans up — best-effort deletes images that are neither the just-selected one nor marked selected. The freshly uploaded image is protected by identity, so a flaky selected flag can never delete it.
  7. Persists the synced cid so the next run skips.

PDS reads are public xrpc calls (no PDS credentials); only the Gravatar token and email are secret.

The project is a zero-dependency Deno app — it uses only Web-platform builtins (fetch, crypto.subtle, FormData, Blob), so there is no lockfile.

Configuration

All config flows in through the environment (no secrets baked into the image):

Var Purpose Default
PDS_URL PDS base URL (e.g. https://pds.example.com) required
HANDLE account handle to resolve to a DID required
GRAVATAR_TOKEN OAuth bearer token (see Token setup) required
GRAVATAR_EMAIL Gravatar account email (SHA-256 hashed at sync time) required
GRAVATAR_API_URL Gravatar REST API base https://api.gravatar.com/v3
STATE_FILE where the last-synced blob cid is persisted /data/gravatar-sync.state

The WPCOM_* vars below are only needed by the deno task login bootstrap command, never by the sync daemon itself.

Running

podman (one-off)

podman run --rm \
  -e PDS_URL=https://pds.example.com \
  -e HANDLE=alice.example.com \
  -e GRAVATAR_TOKEN=\
  -e GRAVATAR_EMAIL=alice@example.com \
  -v ./data:/data:Z \
  forge.l3x.in/alex/atproto-gravatar-sync:latest

Token setup

Gravatar's avatar-write endpoints (POST /me/avatars, …) require a WordPress.com OAuth2 access token with the gravatar-profile:manage scope (a Gravatar-specific scope — the WordPress.com global/auth scopes are not enough and produce 403 insufficient_scope). The included deno task login command runs the official implicit-grant flow (response_type=token, requesting auth, gravatar-profile:read, gravatar-profile:manage) and prints a token to paste into GRAVATAR_TOKEN.

⚠️ Gravatar tokens expire after ~2 weeks. When sync starts failing with insufficient_scope or 401, just re-run deno task login and update GRAVATAR_TOKEN.

To set it up:

  1. Register a WordPress.com application at https://developer.wordpress.com/apps/ and note the client_id. Set its redirect URI to http://localhost:8080/ (or any port — it must be localhost, and must match WPCOM_REDIRECT_URI below). No client secret is needed (implicit grant).
  2. Set these env vars (for the login command only — not needed by the daemon):
    WPCOM_CLIENT_ID=WPCOM_REDIRECT_URI=http://localhost:8080/
    
  3. Run:
    deno task login
    
    Your browser opens the WordPress.com consent page; after you approve, the terminal prints an access token (valid ~2 weeks).
  4. Paste that token into GRAVATAR_TOKEN. Done until it expires.
How the login command works / doing it manually

The implicit grant returns the token in the redirect URL's fragment (#access_token=…), which browsers never send to servers. So deno task login runs a one-shot local HTTP server that serves a tiny page extracting the fragment and POSTing it back.

Authorize (logged into WordPress.com):

https://public-api.wordpress.com/oauth2/authorize?client_id=$WPCOM_CLIENT_ID&redirect_uri=$WPCOM_REDIRECT_URI&response_type=token&scope%5B1%5D=auth&scope%5B2%5D=gravatar-profile:read&scope%5B3%5D=gravatar-profile:manage

The token is in the #access_token=… part of the redirect URL. (You can also obtain one from the in-browser Quick Editor at https://gravatar.com.)