mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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'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" });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user