feat(avatar): self-hosted avatar storage replacing the Gravatar leak

The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 22:39:20 +02:00
1 parent e93feaf8d1
commit ca1c52ffcb
20 files changed
+1470 -46

No files matched your search

@@ -3,11 +3,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The user bubble pulls `useAuth().user.gravatarUrl` to mirror the
// Coach avatar (v1.4.22 B3). SSR rendering through `renderToStaticMarkup`
// has no TanStack-Query provider, so we mock the hook the same way
// The user bubble pulls `useAuth().user.avatarUrl` to mirror the
// Coach avatar (v1.5.5 — self-hosted avatar; replaces v1.4.22 B3
// Gravatar leak). SSR rendering through `renderToStaticMarkup` has
// no TanStack-Query provider, so we mock the hook the same way
// `recommendation-feedback.test.tsx` does — returning a tester user
// with no Gravatar so we exercise the initials fallback path by
// with no avatar so we exercise the initials fallback path by
// default, and override per-test where needed.
const useAuthMock = vi.fn<
() => {
@@ -15,7 +16,7 @@ const useAuthMock = vi.fn<
id: string;
username: string;
role: string;
gravatarUrl: string | null;
avatarUrl: string | null;
};
isAuthenticated: boolean;
isLoading: boolean;
@@ -25,7 +26,7 @@ const useAuthMock = vi.fn<
id: "test-user",
username: "tester",
role: "USER",
gravatarUrl: null,
avatarUrl: null,
},
isAuthenticated: true,
isLoading: false,
@@ -377,15 +378,15 @@ describe("<MessageThread>", () => {
expect(html).not.toContain("What I&#x27;m looking at");
});
it("renders the user bubble with a Gravatar when one is set (B3 parity)", () => {
// v1.4.22 B3 — the user-bubble avatar uses the same dimensions
// as the Coach avatar and pulls the Gravatar URL from useAuth.
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
// The user-bubble avatar uses the same dimensions as the Coach
// avatar and pulls the self-hosted avatar URL from useAuth.
useAuthMock.mockReturnValueOnce({
user: {
id: "u-1",
username: "marc",
role: "USER",
gravatarUrl: "https://www.gravatar.com/avatar/abc123?s=64&d=mp",
avatarUrl: "/api/user/avatar/u-1?v=1700000000000",
},
isAuthenticated: true,
isLoading: false,
@@ -393,15 +394,15 @@ describe("<MessageThread>", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(
/<img[^>]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/,
/<img[^>]+src="\/api\/user\/avatar\/u-1\?v=1700000000000/,
);
expect(html).toMatch(
/data-slot="coach-bubble-user-avatar"[^>]*class="[^"]*size-8/,
);
});
it("falls back to initials when no Gravatar URL is present", () => {
// Default mock returns gravatarUrl: null → initials path.
it("falls back to initials when no avatar URL is present", () => {
// Default mock returns avatarUrl: null → initials path.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
@@ -355,11 +355,11 @@ function ChatBubble({
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
// avatar matches the Coach avatar in size and visual weight.
// Falls back to the generic person glyph when the user hasn't
// configured an email (or the Gravatar lookup returned null).
const gravatarUrl = user?.gravatarUrl ?? null;
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
// Falls back to initials when the user has not uploaded an
// avatar. Replaces the Gravatar leak (v1.4.22 B3).
const avatarUrl = user?.avatarUrl ?? null;
const initials = user?.username
? user.username.slice(0, 2).toUpperCase()
: null;
@@ -377,10 +377,10 @@ function ChatBubble({
>
{content}
</div>
{gravatarUrl ? (
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={gravatarUrl}
src={avatarUrl}
alt=""
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
@@ -18,7 +18,7 @@ const mockUserRef = {
username: "marc",
email: "marc@example.com",
role: "USER" as "USER" | "ADMIN",
gravatarUrl: null,
avatarUrl: null,
},
};
vi.mock("@/hooks/use-auth", () => ({
@@ -89,7 +89,7 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
// already provides per-section nav inside the page. The Admin entry
// must behave EXACTLY like the Settings entry: a single link with no
// sub-list at any route, regardless of where the gravatar dropdown is.
// sub-list at any route, regardless of where the avatar dropdown is.
it("does not render admin entries at all for a regular user", () => {
const html = render({ role: "USER", pathname: "/admin/system-status" });
+5 -5
View File
@@ -100,7 +100,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
const logout = useLogout();
const { theme, setTheme } = useTheme();
const { t } = useTranslations();
const gravatarUrl = user?.gravatarUrl ?? null;
const avatarUrl = user?.avatarUrl ?? null;
if (!user) return null;
@@ -122,7 +122,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="h-9 w-9 shrink-0">
{gravatarUrl && <AvatarImage src={gravatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
@@ -197,8 +197,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<Avatar className="h-8 w-8">
{gravatarUrl && (
<AvatarImage src={gravatarUrl} alt={user.username} />
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
@@ -216,7 +216,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center gap-3 px-2 py-1">
<Avatar className="h-8 w-8 shrink-0">
{gravatarUrl && <AvatarImage src={gravatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
@@ -53,7 +53,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
gravatarUrl: null,
avatarUrl: null,
glucoseUnit: null,
disableCoach,
};