Skip to content

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).

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

  1. 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) when Investigator.NeedsGravatarLookup is 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.
  2. Lookup. GravatarImportWorker imports one Investigator at a time. GravatarImporter reads the Investigator privately (isolated read), checks again that a lookup is still due, and calls GravatarClient: GET https://gravatar.com/avatar/<sha256(trim(lower(primary email)))>?d=404&s=256 through the named client gravatar (5 s timeout, no retry handler).
  3. Outcomes.
  4. 200 with image/png, image/jpeg or image/gif, at most 1 MB, and magic bytes matching the type: stored at public/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 becomes PictureUrl, and gravatarImport records Imported with the stored URL. The picture change raises InvestigatorPictureUpdatedEvent, as an upload does, so Auth0 or Identity is sent the new URL.
  5. 404: gravatarImport records NotFound. Nothing is stored and the picture is unchanged.
  6. 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.
  7. 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.
  8. Browsers. Every Investigator DTO (the base Investigator → InvestigatorDbBaseDto map, inherited by the full and summary DTOs) and GET /api/auth/me withhold a Gravatar URL (null), so the web shows initials (syrf-user-avatar, #4601) until a copy exists. The web also ignores a Gravatar picture claim: 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 (a TimeSpan, default 30.00:00:00, at least one day; start-up fails otherwise) has passed since gravatarImport.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) sets AttemptedAtUtc to DateTime.MinValue (GravatarImport.AddressChangedAtUtc), so the same sign-in offers a lookup of the new address. No address, hash or other marker is stored. Outcome and PictureUrl are 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.

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 legacy auth0|… subject, which Identity's PUT 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 in ApplicationUser.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.

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, 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. 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.

GravatarImporterTests.FlagRoundTrip_KeepsImportedCopiesAndNeverFetchesTwice and GravatarImporterTests.FlagRoundTrip_RefreshesOnlyWhileOnWithDataFromEachState cover on → off → on with data from each state.

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 below covers first imports only.
  • 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).
  • 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: built in #4616 behind accountDeletionPictureRemoval (default off), which removes the whole public/profile-images/{id}/ folder; see Profile pictures removed on account deletion.