Keep atproto PDS and Gravatar profile picture in sync
  • Crystal 96.4%
  • Just 2.2%
  • Dockerfile 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexander Fortin 3702e1b975
All checks were successful
Build and Push Container Image / build-and-push-1 (push) Successful in 1m56s
Build and Push Container Image / build-and-push (push) Successful in 0s
chore(release): v0.1.1
2026-10-31 19:37:24 +07:00
.forgejo/workflows feat: rewrite codebase to Crystal v1.22 2026-10-15 05:58:22 +07:00
.zed feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
spec refactor: cleanup dead code 2026-10-31 19:37:16 +07:00
src refactor: cleanup dead code 2026-10-31 19:37:16 +07:00
.env.example feat: rewrite codebase to Crystal v1.22 2026-10-15 05:58:22 +07:00
.gitignore feat: rewrite codebase to Crystal v1.22 2026-10-15 05:58:22 +07:00
CHANGELOG.md chore(release): v0.1.1 2026-10-31 19:37:24 +07:00
Containerfile feat: rewrite codebase to Crystal v1.22 2026-10-15 05:58:22 +07:00
justfile refactor: cleanup dead code 2026-10-31 19:37:16 +07:00
LICENSE feat: self-contained Deno app mirroring PDS avatar → Gravatar (#2) 2026-07-10 11:47:02 +00:00
README.md feat: rewrite codebase to Crystal v1.22 2026-10-15 05:58:22 +07:00
renovate.json Initial commit 2026-07-09 18:52:27 +00:00
shard.lock refactor: cleanup dead code 2026-10-31 19:37:16 +07:00
shard.yml chore(release): v0.1.1 2026-10-31 19:37:24 +07: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 Crystal app — it uses only the standard library (HTTP::Client, HTTP::FormData, JSON, Digest), 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 atproto-gravatar-sync login bootstrap subcommand, never by the sync run 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

Development

Build and test with the Crystal toolchain (no shards needed):

shards build          # → bin/atproto-gravatar-sync
crystal spec          # run the test suite
crystal tool format   # check formatting with --check

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 atproto-gravatar-sync login 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 atproto-gravatar-sync 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:
    atproto-gravatar-sync 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 atproto-gravatar-sync 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.)