Server-side Gravatar import¶
Feature gravatar-server-import, flag gravatarServerImport (Release, Developing, default off in every
environment). PR #4602; refresh and re-import
#4623 (issue #4605);
administrator backfill #4624 (issue
#4606).
Why¶
Under Auth0, an account without a social picture gets a picture claim that Auth0 builds as
https://s.gravatar.com/avatar/<md5 of email>?…&d=https://cdn.auth0.com/avatars/xx.png. SyRF stored it as
pmInvestigator.PictureUrl and every viewer's browser loaded it. Gravatar (Automattic) therefore received each
viewer's IP address, the member's email hash and the SyRF origin on every page that showed the avatar. On
2026-10-27 production held 609 such URLs (4,173 empty, 123 Google, 51 SyRF uploads).
With the flag on, browsers never contact Gravatar. The server asks Gravatar for each account and keeps a copy in SyRF storage, next to uploaded pictures. It asks again after a long interval (30 days by default) or when the primary address changes, so later Gravatar changes are picked up.
Behaviour with the flag on¶
- Trigger.
GET api/application/signin(every client calls it after authenticating, in Auth0 SPA and BFF modes) offers the Investigator to an in-memory, per-replica queue (GravatarImportQueue, capacity 256) whenInvestigator.NeedsGravatarLookupis true (see Refresh and re-import): either no lookup is recorded yet and the picture is empty or a Gravatar URL (NeedsGravatarImport), or a recorded lookup has fallen due. The request never waits for Gravatar. Support impersonation offers nothing. A full queue drops the offer; the next sign-in offers again. - Lookup.
GravatarImportWorkerimports one Investigator at a time.GravatarImporterreads the Investigator privately (isolated read), checks again that a lookup is still due, and callsGravatarClient:GET https://gravatar.com/avatar/<sha256(trim(lower(primary email)))>?d=404&s=256through the named clientgravatar(5 s timeout, no retry handler). - Outcomes.
- 200 with
image/png,image/jpegorimage/gif, at most 1 MB, and magic bytes matching the type: stored atpublic/profile-images/{userId}/gravatar-{fingerprint}-{guid}.{ext}in the uploads bucket (the fingerprint is the first 16 hex digits of the image's SHA-256, so a refresh can see an unchanged image). It becomesPictureUrl, andgravatarImportrecordsImportedwith the stored URL. The picture change raisesInvestigatorPictureUpdatedEvent, as an upload does, so Auth0 or Identity is sent the new URL. - 404:
gravatarImportrecordsNotFound. Nothing is stored and the picture is unchanged. - Anything else (timeout, network error, other status, wrong type, too large, not an image, storage error):
nothing is recorded, so a later sign-in tries again, but not on the same replica within an hour
(
GravatarImportQueue.RetryDelay), so an outage costs at most one request per account per hour per replica. - Lost race (an upload or sign-in saved the Investigator first): nothing is recorded and the stored copy is deleted. Only the copy this attempt stored is deleted, never the live picture.
- Browsers. Every Investigator DTO (the base
Investigator → InvestigatorDbBaseDtomap, inherited by the full and summary DTOs) andGET /api/auth/mewithhold a Gravatar URL (null), so the web shows initials (syrf-user-avatar, #4601) until a copy exists. The web also ignores a Gravatarpictureclaim: the nav and profile show the stored picture from the user's Investigator record (selectProfilePictureUrl,selectCurrentAuthenticatedInvestigator), because under Auth0 the claim stays the Gravatar URL after the import.
Logs carry the Investigator ID and the outcome only, never the address, its hash or a URL.
Refresh and re-import¶
A recorded lookup becomes due again (Investigator.NeedsGravatarLookup(now, RefreshAfter)) in two cases:
- After an interval.
GravatarImport:RefreshAfter(aTimeSpan, default30.00:00:00, at least one day; start-up fails otherwise) has passed sincegravatarImport.AttemptedAtUtc. No environment sets it. - After a change of primary address.
Investigator.UpdatePrimaryEmail(a profile change, or the sign-in sync from the identity provider) setsAttemptedAtUtctoDateTime.MinValue(GravatarImport.AddressChangedAtUtc), so the same sign-in offers a lookup of the new address. No address, hash or other marker is stored.OutcomeandPictureUrlare kept, so an imported copy stays protected until that lookup.
A due lookup is made only while the picture is still the imported copy (HasImportedGravatarPicture), or the record is
NotFound and the picture is empty or a Gravatar URL. An uploaded or social picture is never replaced, however old the
record is. The outcomes of a refresh:
| Gravatar answers | Account holds an imported copy | Result |
|---|---|---|
| A different image | yes | A new copy is stored and becomes the picture (dispatched). The superseded copy stays in storage (see below). |
| The same image (same fingerprint) | yes | Nothing is stored or dispatched; only AttemptedAtUtc moves on (Unchanged). |
| 404 | yes | The copy is kept: a picture is never taken away automatically. Only AttemptedAtUtc moves on (Unchanged). |
| An image | no (NotFound record) |
Imported as a first import. |
| 404 | no | NotFound is recorded again with the new time. |
| Failure | either | Nothing is recorded, and the one-hour hold applies as before. |
A copy stored before #4605 (gravatar-{guid}, no fingerprint) is replaced once at its first refresh.
A superseded copy is not deleted. The identity provider may still name it in the picture claim: the push of the
new URL runs once with no retry, and pushes for accounts keyed by a legacy auth0| subject are always dropped (#4604).
Browsers render that claim while the flag is off (auth.selectors.ts, BFF session.Picture) and until the
Investigator loads, so deleting it would show a broken image there. Because of the fingerprint, a superseded copy only
appears when the Gravatar image really changes. Clean-up is a follow-up
(#4627), and account deletion (#4607/#4616) removes the whole
folder. S3ProfilePictureStore.DeleteAsync is used only for a copy that never became anyone's picture (a lost race or a
failed save), and deletes only URLs it would have built itself, so it never deletes anything in another environment's
bucket.
Backfill for accounts that do not sign in again (#4606)¶
Sign-in is the only automatic trigger, so while the flag is on an account whose owner never signs in again keeps a withheld Gravatar URL and shows initials (609 production rows on 2026-10-27). An application administrator can import those through the same path, in bounded batches. Nothing starts it: not the host, not a deploy, not a migration.
- Endpoints (
GravatarBackfillController,ApplicationBatchAdminProjectsPolicy, the administrator policy for operator batch jobs): GET api/admin/gravatar-backfillis the dry run. It answers{ "eligible": n }and contacts no one.POST api/admin/gravatar-backfillwith{ "confirm": true, "after": <id or null> }runs one batch and answers{ "stop", "imported", "notFound", "notNeeded", "lostRace", "failed", "next", "remainingEligible" }.- A
POSTwithout"confirm": truegets400 ConfirmRequired. A secondPOSTwhile a batch runs on the same replica gets409 AlreadyRunning. Read-only support impersonation refuses thePOST. - Selection (
IInvestigatorRepository.GetGravatarBackfillCandidatesAsync): an Investigator is selected when it is not deactivated,gravatarImportis absent or null, andPictureUrlis a Gravatar URL (GravatarPicture.IsGravatarUrl, the ruleNeedsGravatarImportuses). Selection runs in_idorder after the cursor. Empty pictures (4,173 in production) are not selected: looking them up would send Gravatar an email hash for accounts that never had a Gravatar URL. MongoDB prefilters with a case-insensitivegravatar\.comregex and the API applies the exact rule. Only the_idindex is used (sort and cursor). The rest is a filter over at most the whole collection (about 5,000 documents in production), so no index is added. - Batch (
GravatarBackfill, bounds inGravatarBackfillLimits.Default): at most 20 Investigators, one at a time, each start at least 1 s after the previous one. No import starts after 20 s (the spacing wait counts), so a call stays well inside the ingress timeout. Each Investigator goes toGravatarImporter.ImportAsync, which re-reads the flag, reads privately, checks again that a lookup is due and saves optimistically, exactly as after a sign-in. The selection is first imports only: recorded lookups that fall due are refreshed at sign-in, never by the backfill. A row that gained a record after selection and was refreshed (Unchanged) counts asnotNeeded. The batch stops: - at the end of the selection (
Completed,nextnull); - at its size (
BatchLimit) or time (TimeLimit); continue fromnext; - when the importer answers
FlagOff(FlagOff, cursor not moved past the unprocessed Investigator); - after 3 failed imports in a row (
RepeatedFailures). Failed rows record nothing and stay eligible. The cursor passes them, so a pass does not retry them, and a later pass fromafter: nulldoes. - State. None outlives the request. A restart, a dropped connection (which cancels the batch, including the import in progress) or a deploy loses nothing; the next call re-scans, and recorded rows have left the selection. That makes a repeated run harmless. The in-memory queue and worker are not used: they are per replica (production autoscales 3 to 10 API replicas), so a feeder's progress and stop control would live on whichever pod took the request.
- Logs. Each batch logs the actor's ID, the stop reason and the counts; each import logs as in the sign-in path.
The backfill reads no flag itself; the importer's read is the gate, so it needs no consumer-manifest entry of its own.
Runbook: running the backfill¶
Run it only on the owner's instruction for that environment. Order: local or a preview first, then staging, then production; staging and production only with the owner's explicit approval.
- Preconditions. The environment's API has S3 credentials for the uploads bucket, and
gravatarServerImportis on there (a GitOps value; outside production a runtime override also works). Use an application administrator's session or bearer token ($TOKENbelow;$APIis the environment's API origin). - Dry run. Record the count:
- Run. Loop until
nextis null. Run one loop at a time per environment: the lock is per replica.
after=null
while :; do
body=$(jq -nc --argjson after "$after" '{confirm: true, after: $after}')
batch=$(curl -sS --fail-with-body -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d "$body" "$API/api/admin/gravatar-backfill") || { echo "$batch"; break; }
echo "$batch"
stop=$(jq -r .stop <<<"$batch")
case "$stop" in Completed|FlagOff|RepeatedFailures) break ;; esac
after=$(jq -c .next <<<"$batch")
done
With after stored as JSON (null or a quoted ID), --argjson passes either. On RepeatedFailures, wait (Gravatar
or storage is in trouble) and resume with after set to that answer's next.
4. Retry pass (optional). One more loop from after=null retries rows that failed transiently.
5. Verify. The dry run reports what is left: rows that keep failing, and any that changed meanwhile. Spot-check
that a few affected accounts now show a picture.
6. Stop. Interrupt the loop. The request in flight is cancelled at once, including a lookup or storage write in
progress, and nothing is recorded for that Investigator. In the rare case the cancellation lands just after the S3
write completes, one unreferenced copy can stay in the account's folder (removed with the folder by
#4607). Switching the flag off also stops the batch, but returns
Gravatar URLs to browsers.
Rolling back is not needed: imported copies are SyRF URLs, valid and shown in every flag state, and a NotFound record
only defers the next lookup to a sign-in after RefreshAfter. Removing imported copies is #4607.
Sign-in keeps an imported copy (every flag state)¶
Sign-in copies the identity provider's picture claim onto the Investigator (ApplicationService →
Investigator.ApplySignInPicture). A claim that is empty or a Gravatar URL never replaces an imported copy, and nor
does a claim naming one of the account's imported copies change the picture at all, whatever the current picture is
(GravatarPicture.IsImportedCopyUrl). The provider only learns such URLs from SyRF, so the claim is never newer than
the Investigator: after a refresh, or after an upload whose push failed, it can hold an older copy's URL for good. Any
other claim (for example a Google picture) replaces it, as before. This is not flag-gated: only Investigators
carrying an Imported record are affected, and only the flag creates those, so nothing changes for anyone else.
The guard is needed because the identity provider does not reliably learn about the copy:
- Under Auth0 the claim for a database user is the Gravatar URL until Auth0's stored picture is updated by the event handler, and a social login's picture is re-synced by Auth0 at each login.
- Profile pushes to Identity are keyed by
Investigator.Auth0Id. For an account migrated from Auth0 that is the legacyauth0|…subject, which Identity'sPUT api/admin/users/{id}does not find (it answers 404 and the push is dropped with a warning). Such accounts keep the migrated Gravatar URL inApplicationUser.PictureUrl. This is a pre-existing gap that also affects uploads, names and email changes: #4604.
Data¶
pmInvestigator.gravatarImport ({ AttemptedAtUtc, Outcome: "Imported" | "NotFound", PictureUrl }) is additive
and absent until the first lookup. An older binary carries it through Entity.ExtraElements and writes it back
unchanged (GravatarImportTests.GravatarImport_SurvivesASaveByABinaryWithoutTheMember). No migration, no index (the
backfill's selection uses only _id; see above).
Refresh (#4605) adds no element and no member. It rewrites AttemptedAtUtc (a refresh time, or DateTime.MinValue
after an address change), and the DateTime.MinValue date round-trips through BSON
(GravatarImport_AnAddressChangedRecordRoundTripsThroughBson,
GravatarImport_AnAddressChangedRecordSurvivesASaveByABinaryWithoutTheMember). No member was added inside the record on
purpose: the #4602 binary would refuse an unknown element there, because the record does not ignore extra elements. A
binary without refresh reads any of these records as an ordinary recorded lookup and makes no further lookup.
Flag round trips¶
- On → off: nothing is queued, looked up or refreshed, imports already queued stop (
FlagOff), and a running backfill batch stops at its next Investigator. Gravatar URLs are returned and shown as stored, as before. Imported copies are SyRF URLs, so they stay and are shown; the sign-in guard keeps them. A change of primary address still marks an existing record due (not flag-gated, and only Investigators with a record, which only the flag creates, are affected). - Off → on: accounts whose recorded lookup is still recent are not looked up again. Records that fell due while the flag was off, including those whose address changed, are refreshed at their next sign-in, and the rest are imported at their next sign-in or by the backfill.
GravatarImporterTests.FlagRoundTrip_KeepsImportedCopiesAndNeverFetchesTwice and
GravatarImporterTests.FlagRoundTrip_RefreshesOnlyWhileOnWithDataFromEachState cover on → off → on with data from
each state; GravatarBackfillTests.FlagRoundTrip_KeepsEachStatesRowsAndNeverLooksUpTwice does the same for the
backfill.
Flag decision (#4605): refresh reuses gravatarServerImport and adds no flag. The feature is still Developing and
default off everywhere, and refresh is part of the same unreleased behaviour, behind the same checks in the queue and
the importer.
Before switching it on¶
- It only works in environments whose API has S3 credentials for the uploads bucket (as uploads do).
Not in this feature (follow-ups)¶
- Naming Gravatar in the privacy notice: drafted, on hold by owner decision (2026-10-27) and not a precondition for switching the flag on (#4610).
- Refresh is sign-in driven: an account that never signs in again is never refreshed. The backfill (above) covers first imports only.
- Accounts with an empty picture are not backfilled (see the backfill's selection); they get a lookup at their next sign-in, as before.
- Cleaning up superseded copies after a refresh (#4627). This also covers checking in staging that Gravatar serves byte-identical images, so an unchanged image keeps its fingerprint.
- Mirroring a removed Gravatar: a refresh that gets a 404 keeps the imported copy, and so does a change to an address with no Gravatar (#4625).
- A "use my Gravatar" action on the profile page (an option in #4605, not built).
- Identity profile pushes keyed by the legacy
auth0|subject (see above): #4604. - Removing imported copies (and uploads) from S3 when an account is deleted: built in
#4616 behind
accountDeletionPictureRemoval(default off), which removes the wholepublic/profile-images/{id}/folder; see Profile pictures removed on account deletion.