- TypeScript 96.9%
- Dockerfile 3.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| .zed | ||
| lib | ||
| .env.example | ||
| .gitignore | ||
| Containerfile | ||
| deno.json | ||
| LICENSE | ||
| login.ts | ||
| main.ts | ||
| main_test.ts | ||
| README.md | ||
| renovate.json | ||
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:
- 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. - Resolves the handle → DID via the PDS (
com.atproto.identity.resolveHandle). - Reads the profile record (
app.bsky.actor.profile) → avatar blob cid + mime type. - 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.
- 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)}). - 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 flakyselectedflag can never delete it. - 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:
- Register a WordPress.com application at https://developer.wordpress.com/apps/ and note the
client_id. Set its redirect URI tohttp://localhost:8080/(or any port — it must belocalhost, and must matchWPCOM_REDIRECT_URIbelow). No client secret is needed (implicit grant). - Set these env vars (for the login command only — not needed by the daemon):
WPCOM_CLIENT_ID=… WPCOM_REDIRECT_URI=http://localhost:8080/ - Run:
Your browser opens the WordPress.com consent page; after you approve, the terminal prints an access token (valid ~2 weeks).deno task login - 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.)