Server-side Gravatar import¶
Feature gravatar-server-import, flag gravatarServerImport (Release, Developing, default off in every
environment). PR #4602.
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 once per account and keeps a copy in SyRF storage, next to uploaded pictures.
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.NeedsGravatarImportis true: no lookup recorded yet, and the picture is empty or a Gravatar URL. 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 it still needs an import, 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-{guid}.{ext}in the uploads bucket. 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.
- 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.
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; 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.
Flag round trips¶
- On → off: nothing is queued or looked up, and imports already queued stop (
FlagOff). 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. - Off → on: accounts with a recorded lookup are not looked up again; the rest are imported at their next sign-in.
GravatarImporterTests.FlagRoundTrip_KeepsImportedCopiesAndNeverFetchesTwice covers on → off → on with data from
each state.
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 or re-import: one lookup per account; a later Gravatar change, or a new primary address, is not picked up (#4605).
- A bulk backfill for the 609 production rows whose owners never sign in again (until then they show initials while the flag is on): #4606.
- Identity profile pushes keyed by the legacy
auth0|subject (see above): #4604. - Removing imported copies (and uploads) from S3 when an account is deleted: #4607.