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

+9 -2
View File
@@ -1,8 +1,8 @@
import { apiSuccess } from "@/lib/api-response";
import { getGravatarUrl } from "@/lib/gravatar";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { buildAvatarUrl } from "@/lib/avatar";
export const dynamic = "force-dynamic";
@@ -29,7 +29,14 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
// URL is relative so PWA + native clients render identically
// and the `?v={updatedAtMs}` suffix busts the browser cache on
// a re-upload. Null when the user has not uploaded an avatar
// yet; clients paint the username-initials fallback.
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
// Narrow the `apiHandler` signature for the test fixtures (see
// disable-coach/__tests__/route.test.ts for the precedent).
type RouteCtx = { params: Promise<{ id: string }> };
const get = GET as (r: Request, ctx: RouteCtx) => Promise<Response>;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
}
function mkRequest() {
return new Request("http://localhost/api/user/avatar/user-1");
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/user/avatar/{id}", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await get(mkRequest(), ctx("user-1"));
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("refuses cross-user reads with 403", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await get(mkRequest(), ctx("someone-else"));
expect(res.status).toBe(403);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns 404 when the user has no avatar", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
avatarBytes: null,
avatarContentType: null,
avatarUpdatedAt: null,
} as never);
const res = await get(mkRequest(), ctx("user-1"));
expect(res.status).toBe(404);
});
it("returns the bytes with the persisted content-type", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
avatarBytes: bytes,
avatarContentType: "image/jpeg",
avatarUpdatedAt: new Date(),
} as never);
const res = await get(mkRequest(), ctx("user-1"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/jpeg");
expect(res.headers.get("cache-control")).toContain("immutable");
const echoed = Buffer.from(await res.arrayBuffer());
expect(echoed.equals(bytes)).toBe(true);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* v1.5.5 — owner-scoped avatar GET.
*
* GET /api/user/avatar/{userId}
*
* Serves the avatar bytes the user uploaded through POST
* /api/user/avatar. Owner-scoped — `requireAuth()` resolves the
* caller's id and the `userId` path param must match. Public-by-
* default would expose an authenticated user's avatar to anyone who
* guessed (or scraped) the cuid; HealthLog is single-tenant per
* user, so cross-user reads are a privacy hole even if the bytes
* themselves look innocuous.
*
* 200 returns the raw image bytes with the persisted `Content-Type`
* (`image/jpeg` / `image/png` / `image/webp`) and a `Cache-Control`
* that lets the client cache aggressively — the /me payload appends
* `?v={updatedAtMs}` so a re-upload flips the URL and the next
* paint round-trips.
*
* 404 fires when the user row has no avatar; the client paints the
* username-initials fallback.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
export const GET = apiHandler(async (_request: Request, ctx: Context) => {
const { user } = await requireAuth();
const { id } = await ctx.params;
// Owner-scope. A cookie session and a Bearer token both arrive
// through `requireAuth()`; the id-match below covers both. There
// is no admin-elevation path here (admin avatars are not a
// server-side ops concern).
if (id !== user.id) {
annotate({
action: { name: "user.avatar.get.forbidden" },
});
return apiError("Forbidden", 403);
}
const row = await prisma.user.findUnique({
where: { id: user.id },
select: {
avatarBytes: true,
avatarContentType: true,
avatarUpdatedAt: true,
},
});
if (!row?.avatarBytes || !row.avatarContentType) {
return apiError("Avatar not found", 404);
}
annotate({
action: { name: "user.avatar.get" },
meta: {
contentType: row.avatarContentType,
size: row.avatarBytes.length,
},
});
// The /me payload carries `?v={updatedAtMs}` on the URL so the
// browser cache invalidates on every re-upload. The bytes
// themselves are stable for the lifetime of that URL.
return new NextResponse(row.avatarBytes as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": row.avatarContentType,
"Content-Length": String(row.avatarBytes.length),
"Cache-Control": "private, max-age=31536000, immutable",
},
});
});
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3_600_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
// `apiHandler` types as `NextRequest → Promise<Response>`; the test
// fixtures hand in a plain `Request` which is structurally compatible
// for everything the handler reads, so we narrow the call type the
// same way `disable-coach/__tests__/route.test.ts` does.
const post = POST as (r: Request) => Promise<Response>;
const del = DELETE as (r: Request) => Promise<Response>;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
/**
* Minimal valid PNG: 1x1 white pixel. The Vitest `node` env has a
* polyfilled `File` + `Blob` + `FormData` so we can build a multipart
* body without an extra library.
*/
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
function pngBuffer(): Buffer {
return Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
}
function mkUpload(body: Buffer, filename = "avatar.png", type = "image/png") {
const formData = new FormData();
// Copy into a plain Uint8Array — Node's `Buffer<ArrayBufferLike>`
// is not structurally assignable to `BlobPart` because of the
// ArrayBufferLike vs. ArrayBuffer mismatch on the `.buffer` type.
const blobBytes = new Uint8Array(body.byteLength);
blobBytes.set(body);
const blob = new Blob([blobBytes], { type });
formData.append("file", blob, filename);
return new Request("http://localhost/api/user/avatar", {
method: "POST",
body: formData,
});
}
beforeEach(() => {
vi.clearAllMocks();
// Default rate-limit pass-through; per-test overrides drop in below.
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
});
describe("POST /api/user/avatar", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await post(mkUpload(pngBuffer()));
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("stores a valid PNG and returns the cache-busting URL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await post(mkUpload(pngBuffer()));
expect(res.status).toBe(201);
const env = (await res.json()) as {
data: { avatarUrl: string; contentType: string; updatedAt: string };
};
expect(env.data.contentType).toBe("image/png");
expect(env.data.avatarUrl).toMatch(/^\/api\/user\/avatar\/user-1\?v=\d+$/);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
avatarContentType: "image/png",
}),
}),
);
expect(auditLog).toHaveBeenCalledWith(
"user.avatar.upload",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
contentType: "image/png",
}),
}),
);
});
it("rejects a body larger than 2 MiB with 413", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// 2 MiB + 1 byte. The PNG signature at the front keeps the
// detector happy, but the size check trips first.
const tooBig = Buffer.concat([pngBuffer(), Buffer.alloc(2 * 1024 * 1024)]);
const res = await post(mkUpload(tooBig));
expect(res.status).toBe(413);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Plain text content — no JPEG/PNG/WebP magic.
const notAnImage = Buffer.from("hello, world — definitely not an image");
const res = await post(
mkUpload(notAnImage, "avatar.png", "image/png"),
);
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a missing 'file' field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const formData = new FormData();
formData.append("other", "ignored");
const req = new Request("http://localhost/api/user/avatar", {
method: "POST",
body: formData,
});
const res = await post(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("trips the per-user rate-limit with 429", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await post(mkUpload(pngBuffer()));
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("DELETE /api/user/avatar", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await del(
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("clears the row and returns 204", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
avatarContentType: "image/png",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await del(
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
);
expect(res.status).toBe(204);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
avatarBytes: null,
avatarContentType: null,
avatarUpdatedAt: null,
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.avatar.delete",
expect.objectContaining({ userId: "user-1" }),
);
});
it("skips the audit row when the avatar was already empty", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
avatarContentType: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await del(
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
);
expect(res.status).toBe(204);
expect(auditLog).not.toHaveBeenCalled();
});
});
+243
View File
@@ -0,0 +1,243 @@
/**
* v1.5.5 — self-hosted avatar storage.
*
* POST /api/user/avatar — multipart upload, owner-scoped.
* DELETE /api/user/avatar — clears the row, owner-scoped.
*
* Replaces the Gravatar third-party 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 own known-email table on
* each request. Storing the image bytes on the User row and serving
* them from same-origin closes the gap without adding a filesystem
* volume or a new compose change (pg_dump already carries the
* bytes alongside the rest of the row).
*
* Validation:
* - Max body size: 2 MiB (small enough to keep the request pipeline
* responsive, big enough for a 512×512 JPEG).
* - Accepted MIME: image/jpeg, image/png, image/webp. Anything else
* fails 415.
* - The Content-Type sent by the multipart header is informational
* only — the route sniffs the magic bytes and refuses a mismatch.
* - The image is decoded just enough to read its native width +
* height; > 2048×2048 fails 413 so a single user cannot park a
* huge PNG in the database.
*
* Storage choice: image bytes on the User row (Postgres bytea). The
* file alternative needs a new volume mount + a new compose change
* + a separate backup story; the byte alternative slots into the
* existing backup path with no operator change. Avatars stay small
* (< 2 MiB hard cap × < 100 users on a typical self-host = < 200 MB
* total), so the row-size trade-off is well inside budget.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
AVATAR_MAX_BYTES,
buildAvatarUrl,
detectAvatarMimeType,
readAvatarDimensions,
} from "@/lib/avatar";
export const dynamic = "force-dynamic";
/**
* Per-user upload rate-limit. The Settings → Account avatar control
* is a deliberate user action, not an automation surface, so a tight
* cap (10 / hour / user) is comfortable for real flows and tripwires
* a misbehaving client without surfacing as a 429 on a normal
* re-upload.
*/
const UPLOAD_LIMIT_PER_HOUR = 10;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
interface UploadResponse {
avatarUrl: string;
contentType: string;
updatedAt: string;
}
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`user-avatar-upload:${user.id}`,
UPLOAD_LIMIT_PER_HOUR,
UPLOAD_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many avatar uploads", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
// Pre-flight on the declared content length so a multi-gigabyte
// body never lands in the FormData parser. The hard limit below
// is the buffered-size check; the header guard just spares the
// allocation when the abuse is obvious.
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > AVATAR_MAX_BYTES) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "content_length_exceeded", contentLength },
});
return apiError(
`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
413,
);
}
let formData: FormData;
try {
formData = await request.formData();
} catch (err) {
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
if (file.size > AVATAR_MAX_BYTES) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "file_size_exceeded", size: file.size },
});
return apiError(
`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
413,
);
}
let buffer: Buffer;
try {
buffer = Buffer.from(await file.arrayBuffer());
} catch {
return apiError("Failed to read uploaded file", 400);
}
// Magic-byte sniff. The multipart Content-Type header is operator-
// controlled (any client can send `image/jpeg` over a PNG body), so
// the wire-side header is informational only.
const sniffed = detectAvatarMimeType(buffer);
if (!sniffed) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "unsupported_mime" },
});
return apiError(
"Unsupported image type. Use JPEG, PNG, or WebP.",
415,
);
}
const dimensions = readAvatarDimensions(buffer, sniffed);
if (!dimensions) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "dimensions_unreadable" },
});
return apiError("Image dimensions could not be read", 422);
}
if (dimensions.width > 2048 || dimensions.height > 2048) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: {
reason: "dimensions_exceeded",
width: dimensions.width,
height: dimensions.height,
},
});
return apiError(
`Image exceeds the 2048×2048 dimension limit`,
413,
);
}
const updatedAt = new Date();
// Prisma's Bytes column types as `Uint8Array<ArrayBuffer>`; Node's
// `Buffer` extends `Uint8Array` but is generic over
// `ArrayBufferLike`. Allocating a fresh `Uint8Array(N).set(buffer)`
// copies the bytes into an `ArrayBuffer`-backed array so the
// structural assignment to the Prisma `Bytes?` field type-checks
// cleanly.
const avatarBytes = new Uint8Array(buffer.byteLength);
avatarBytes.set(buffer);
await prisma.user.update({
where: { id: user.id },
data: {
avatarBytes,
avatarContentType: sniffed,
avatarUpdatedAt: updatedAt,
},
});
await auditLog("user.avatar.upload", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
contentType: sniffed,
size: buffer.length,
width: dimensions.width,
height: dimensions.height,
},
});
annotate({
action: { name: "user.avatar.upload" },
meta: { contentType: sniffed, size: buffer.length },
});
const response: UploadResponse = {
avatarUrl: buildAvatarUrl(user.id, updatedAt),
contentType: sniffed,
updatedAt: updatedAt.toISOString(),
};
return apiSuccess(response, 201);
});
export const DELETE = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
// Only audit the actual clear so a "delete on an already-empty
// row" is a no-op for the ledger. The DB write fires either way
// for code simplicity; the size of the row delta is zero when the
// columns were already null.
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { avatarContentType: true },
});
await prisma.user.update({
where: { id: user.id },
data: {
avatarBytes: null,
avatarContentType: null,
avatarUpdatedAt: null,
},
});
if (previous?.avatarContentType) {
await auditLog("user.avatar.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { previousContentType: previous.avatarContentType },
});
}
annotate({ action: { name: "user.avatar.delete" } });
return new Response(null, { status: 204 });
});