Avatars: from Gravatar by the primary email first, from an uploaded image later #7

Closed
opened 2026-09-05 12:00:55 +00:00 by tiagoagueda · 0 comments
Owner

Observation

avatar for users, on the first stage preferable from gravatar using main email, and maybe in the future, from user's media.

What exists today

Nothing. The shell shows the account as text — user.display_name in src/postulo/templates/base.html — and the profile page has no image. There is no image handling anywhere in the interface.

Two things that shape the design are already true, checked rather than assumed:

  • Production CSP is img-src 'self' data: (src/postulo/config/settings/prod.py). A Gravatar image referenced by URL in the page would not load at all. Relaxing the policy to admit gravatar.com is possible, but see the next point for why it is the wrong fix.
  • Pillow 12.3 is already installed, as a dependency of WeasyPrint. Resizing and re-encoding an uploaded image costs no new dependency.

Stage one: Gravatar, by the primary address

Fetch it server-side, once, and serve it ourselves. Not <img src="https://gravatar.com/avatar/…">.

The reason is the project's own principle: Postulo makes no outbound request on its own, and it stores the personal documents of people who are, right now, exposed. Embedding a Gravatar URL in the page makes every page view a request from the reader's browser to Automattic, carrying their IP and a hash of their email — and the hash of an address is reversible for any address that appears in a breach corpus. A server-side fetch means one request, from the server, when the person turns the feature on, and never again until they change address or ask for a refresh. It also keeps the CSP exactly as it is.

  • Opt-in, per person. A checkbox on Your details: "Use my Gravatar". Off by default. The help text says what will be fetched and from whom, once.
  • Hash: SHA-256 of the trimmed, lower-cased primary address (Gravatar's current scheme; MD5 is legacy). Request with ?d=404&s=256 so an absent avatar is a clean miss rather than a generated placeholder.
  • Store the result under private media (data/media/avatars/<user>/…) and serve it through serve_private_file (src/postulo/core/files.py), same as every other personal file. Cache headers can be friendlier than for CVs — it is not sensitive — but it still never comes from the web server directly.
  • Refresh when the primary address changes (#3 makes "primary" a real concept) and on demand from the profile page.
  • The default when there is no Gravatar, or it is off: an initials avatar, generated locally as inline SVG from the account's name (#2 makes the name reliable). No request, no dependency, and it looks intentional rather than broken.

Stage two: an uploaded image

Profile.avatar as an ImageField under private media, with what an upload of a personal photograph needs:

  • validate type, size and dimensions; re-encode with Pillow to a fixed square (256px is plenty for the shell; keep a 512 for documents), which also strips EXIF — a phone photograph carries the location it was taken at, and that should not survive an upload into a job-search tool;
  • serve through the same private view;
  • precedence: uploaded image › Gravatar (if opted in) › initials.

A third source arrives with #6 for free: OIDC providers commonly send a picture claim. Worth wiring once the other two exist, behind the same opt-in.

Where it shows, and where it deliberately does not

  • The shell, beside the name in the navigation, and on Your details.
  • On CVs: optional, off by default, and never on by surprise. Whether a photograph belongs on a CV depends entirely on the country — expected in much of continental Europe, actively discouraged in the UK and the US, where it invites discrimination and some employers discard CVs that carry one. A per-CV switch, defaulting to off, is the only defensible behaviour. Not required for this issue; noted so the model leaves room for it.

Other touches

  • Export/import (src/postulo/core/export.py, importer.py): the avatar file joins the media archive and the account section, so it survives a move between instances.
  • seed_demo: an initials avatar needs nothing; leave Gravatar off for the fictional persona.

A note on modularity

The mission is a plugin interface wherever a choice could reasonably vary, and it is worth saying explicitly why this is not one. Two sources — a lookup and an upload — with a fixed precedence are core behaviour with no plausible third implementation that a separately installed package would provide better than a claim from #6. An "avatar source" plugin group would be modularity by reflex. If that ever changes, the precedence list is the seam.

Classification

An enhancement. Not breaking: a nullable field and a checkbox, off by default; no existing behaviour changes.

Open questions

  1. Should the Gravatar fetch happen synchronously when the box is ticked (one request, a second's wait, and the person sees the result) or through the tasks framework? Synchronous is simpler and this is the one request that is actually worth waiting for.
  2. Initials avatar: one colour, or a colour derived from the name so two people on an instance look different? The latter costs nothing.
  3. Whether to honour a Gravatar rating (?r=g) at all — a personal avatar on a personal instance arguably needs no rating filter.
## Observation > avatar for users, on the first stage preferable from gravatar using main email, and maybe in the future, from user's media. ## What exists today Nothing. The shell shows the account as text — `user.display_name` in `src/postulo/templates/base.html` — and the profile page has no image. There is no image handling anywhere in the interface. Two things that shape the design are already true, checked rather than assumed: - **Production CSP is `img-src 'self' data:`** (`src/postulo/config/settings/prod.py`). A Gravatar image referenced by URL in the page would not load at all. Relaxing the policy to admit `gravatar.com` is possible, but see the next point for why it is the wrong fix. - **Pillow 12.3 is already installed**, as a dependency of WeasyPrint. Resizing and re-encoding an uploaded image costs no new dependency. ## Stage one: Gravatar, by the primary address **Fetch it server-side, once, and serve it ourselves.** Not `<img src="https://gravatar.com/avatar/…">`. The reason is the project's own principle: Postulo makes no outbound request on its own, and it stores the personal documents of people who are, right now, exposed. Embedding a Gravatar URL in the page makes *every page view* a request from the reader's browser to Automattic, carrying their IP and a hash of their email — and the hash of an address is reversible for any address that appears in a breach corpus. A server-side fetch means one request, from the server, when the person turns the feature on, and never again until they change address or ask for a refresh. It also keeps the CSP exactly as it is. - **Opt-in, per person.** A checkbox on *Your details*: "Use my Gravatar". Off by default. The help text says what will be fetched and from whom, once. - **Hash:** SHA-256 of the trimmed, lower-cased primary address (Gravatar's current scheme; MD5 is legacy). Request with `?d=404&s=256` so an absent avatar is a clean miss rather than a generated placeholder. - **Store** the result under private media (`data/media/avatars/<user>/…`) and serve it through `serve_private_file` (`src/postulo/core/files.py`), same as every other personal file. Cache headers can be friendlier than for CVs — it is not sensitive — but it still never comes from the web server directly. - **Refresh** when the primary address changes (#3 makes "primary" a real concept) and on demand from the profile page. - **The default when there is no Gravatar, or it is off:** an initials avatar, generated locally as inline SVG from the account's name (#2 makes the name reliable). No request, no dependency, and it looks intentional rather than broken. ## Stage two: an uploaded image `Profile.avatar` as an `ImageField` under private media, with what an upload of a personal photograph needs: - validate type, size and dimensions; re-encode with Pillow to a fixed square (256px is plenty for the shell; keep a 512 for documents), which also **strips EXIF** — a phone photograph carries the location it was taken at, and that should not survive an upload into a job-search tool; - serve through the same private view; - precedence: uploaded image › Gravatar (if opted in) › initials. A third source arrives with #6 for free: OIDC providers commonly send a `picture` claim. Worth wiring once the other two exist, behind the same opt-in. ## Where it shows, and where it deliberately does not - The shell, beside the name in the navigation, and on *Your details*. - **On CVs: optional, off by default, and never on by surprise.** Whether a photograph belongs on a CV depends entirely on the country — expected in much of continental Europe, actively discouraged in the UK and the US, where it invites discrimination and some employers discard CVs that carry one. A per-CV switch, defaulting to off, is the only defensible behaviour. Not required for this issue; noted so the model leaves room for it. ## Other touches - **Export/import** (`src/postulo/core/export.py`, `importer.py`): the avatar file joins the media archive and the `account` section, so it survives a move between instances. - **`seed_demo`**: an initials avatar needs nothing; leave Gravatar off for the fictional persona. ## A note on modularity The mission is a plugin interface wherever a choice could reasonably vary, and it is worth saying explicitly why this is *not* one. Two sources — a lookup and an upload — with a fixed precedence are core behaviour with no plausible third implementation that a separately installed package would provide better than a claim from #6. An "avatar source" plugin group would be modularity by reflex. If that ever changes, the precedence list is the seam. ## Classification An enhancement. Not breaking: a nullable field and a checkbox, off by default; no existing behaviour changes. ## Open questions 1. Should the Gravatar fetch happen synchronously when the box is ticked (one request, a second's wait, and the person sees the result) or through the tasks framework? Synchronous is simpler and this is the one request that is actually worth waiting for. 2. Initials avatar: one colour, or a colour derived from the name so two people on an instance look different? The latter costs nothing. 3. Whether to honour a Gravatar rating (`?r=g`) at all — a personal avatar on a personal instance arguably needs no rating filter.
tiagoagueda added this to the 0.2.0 milestone 2026-09-05 12:00:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#7
No description provided.