Skip to content

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

  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.NeedsGravatarImport is 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.
  2. Lookup. GravatarImportWorker imports one Investigator at a time. GravatarImporter reads the Investigator privately (isolated read), checks again that it still needs an import, 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-{guid}.{ext} in the uploads bucket. 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.
  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.

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

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.