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

+138
View File
@@ -1418,6 +1418,109 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/user/avatar:
post:
tags:
- Auth
summary: Upload the calling user's avatar
description: "Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the
magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048
dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from
same-origin."
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
description: Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.
required:
- file
responses:
"201":
description: Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches
on re-upload.
content:
application/json:
schema:
$ref: "#/components/schemas/UploadAvatarResponse"
"401": *a1
"413":
description: Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"415":
description: Image format is not one of JPEG / PNG / WebP (magic-byte sniff).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a2
"429": *a3
delete:
tags:
- Auth
summary: Clear the calling user's avatar
description: Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no
audit-log row.
responses:
"204":
description: Avatar cleared (or already empty).
"401": *a1
"422": *a2
"429": *a3
/api/user/avatar/{id}:
get:
tags:
- Auth
summary: Serve a user's avatar bytes
description: Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403.
Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends
`?v={updatedAtMs}` so clients can cache aggressively.
parameters:
- in: path
name: id
schema:
type: string
description: User id (must match the calling user).
required: true
description: User id (must match the calling user).
responses:
"200":
description: Avatar bytes.
content:
image/jpeg:
schema:
type: string
description: Raw JPEG bytes.
image/png:
schema:
type: string
description: Raw PNG bytes.
image/webp:
schema:
type: string
description: Raw WebP bytes.
"401": *a1
"403":
description: Caller is not the avatar's owner.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: The user has no uploaded avatar.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a2
"429": *a3
/api/insights/chat/messages/{id}/feedback:
post:
tags:
@@ -4160,6 +4263,41 @@ components:
- data
- error
additionalProperties: false
UploadAvatarResponse:
type: object
properties:
data:
type: object
properties:
avatarUrl:
type: string
contentType:
type: string
enum:
- image/jpeg
- image/png
- image/webp
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- avatarUrl
- contentType
- updatedAt
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachMessageFeedbackResponse:
type: object
properties:
@@ -0,0 +1,24 @@
-- v1.5.5 — self-hosted avatar storage.
--
-- 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 known-email table on each
-- request. The new path stores the image bytes on the User row and
-- serves them from same-origin.
--
-- Three nullable columns; null on all three = the user has not
-- uploaded an avatar and the profile response returns
-- `avatarUrl: null`. The upload route guards the size + content-
-- type + dimensions before the row write, so the column-level
-- constraints intentionally stay loose (the validation is enforced
-- at the API layer where it can return a useful 4xx).
--
-- The bytes ride in pg_dump alongside the rest of the row, so the
-- standard backup path already covers them. No new filesystem
-- volume to mount, no new Docker compose change.
ALTER TABLE "users"
ADD COLUMN "avatar_bytes" BYTEA,
ADD COLUMN "avatar_content_type" TEXT,
ADD COLUMN "avatar_updated_at" TIMESTAMP(3);
+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 });
});
@@ -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,
};
+7 -1
View File
@@ -21,7 +21,13 @@ export interface AuthUser {
* `<TourLauncher>` component for the gating logic.
*/
onboardingTourCompleted: boolean;
gravatarUrl: string | null;
/**
* v1.5.5 — relative URL of the user's self-hosted avatar, served
* from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the
* Gravatar leak; null when the user has not uploaded an avatar
* yet (clients paint the username-initials fallback).
*/
avatarUrl: string | null;
glucoseUnit: string | null;
/**
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
AVATAR_MAX_BYTES,
buildAvatarUrl,
detectAvatarMimeType,
readAvatarDimensions,
} from "@/lib/avatar";
/** Single 1×1 white PNG. */
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
/** Minimal JPEG: SOI + APP0 + SOF0(1×1) + EOI. */
const JPEG_1X1 = Buffer.from([
0xff, 0xd8, // SOI
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00,
0xff, 0xc0, 0x00, 0x0b, 0x08,
0x00, 0x01, // height = 1
0x00, 0x01, // width = 1
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9, // EOI
]);
/**
* Minimal lossy WebP: RIFF/WEBP + VP8 header with width=1, height=1.
* The size + offset layout matches the parser in `readWebpDimensions`.
*/
const WEBP_VP8_1X1 = (() => {
const b = Buffer.alloc(32);
b.write("RIFF", 0, "ascii");
b.writeUInt32LE(24, 4); // file size (informational)
b.write("WEBP", 8, "ascii");
b.write("VP8 ", 12, "ascii");
b.writeUInt32LE(16, 16); // chunk size (informational)
// VP8 frame header: 3-byte frame tag + 0x9D 0x01 0x2A signature +
// 16-bit little-endian width / height with the top 2 bits reserved.
b.writeUInt8(0x9d, 23);
b.writeUInt8(0x01, 24);
b.writeUInt8(0x2a, 25);
b.writeUInt16LE(1, 26); // width = 1
b.writeUInt16LE(1, 28); // height = 1
return b;
})();
describe("detectAvatarMimeType", () => {
it("identifies a PNG signature", () => {
expect(detectAvatarMimeType(PNG_1X1)).toBe("image/png");
});
it("identifies a JPEG signature", () => {
expect(detectAvatarMimeType(JPEG_1X1)).toBe("image/jpeg");
});
it("identifies a WebP signature", () => {
expect(detectAvatarMimeType(WEBP_VP8_1X1)).toBe("image/webp");
});
it("returns null for an unsupported MIME", () => {
expect(detectAvatarMimeType(Buffer.from("not an image, just text"))).toBe(
null,
);
});
it("returns null for a buffer too short to sniff", () => {
expect(detectAvatarMimeType(Buffer.from([0xff, 0xd8]))).toBe(null);
});
});
describe("readAvatarDimensions", () => {
it("reads PNG dimensions from IHDR", () => {
expect(readAvatarDimensions(PNG_1X1, "image/png")).toEqual({
width: 1,
height: 1,
});
});
it("reads JPEG dimensions from the first SOF marker", () => {
expect(readAvatarDimensions(JPEG_1X1, "image/jpeg")).toEqual({
width: 1,
height: 1,
});
});
it("reads VP8 WebP dimensions", () => {
expect(readAvatarDimensions(WEBP_VP8_1X1, "image/webp")).toEqual({
width: 1,
height: 1,
});
});
});
describe("buildAvatarUrl", () => {
it("returns a relative URL with a unix-ms cache-bust suffix", () => {
const ts = new Date(1_700_000_000_000);
expect(buildAvatarUrl("user-abc", ts)).toBe(
"/api/user/avatar/user-abc?v=1700000000000",
);
});
});
describe("AVATAR_MAX_BYTES", () => {
it("caps uploads at 2 MiB", () => {
expect(AVATAR_MAX_BYTES).toBe(2 * 1024 * 1024);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* v1.5.5 — avatar helpers shared by the /api/user/avatar routes and
* the /api/auth/me profile response.
*
* Self-hosted replacement for the Gravatar third-party leak. The
* helpers stay independent of any image-processing dependency: the
* MIME sniff reads the magic bytes, and the dimension probe walks
* the format's header bytes by hand. Adding a heavyweight image
* library (sharp / jimp) would pull a native dependency into the
* Alpine standalone image just to read width + height — the hand-
* rolled parser is < 100 LOC and covers JPEG / PNG / WebP.
*/
/** Hard upload cap. 2 MiB is comfortable for a 512×512 JPEG. */
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
/** Allowed content-type values, in canonical lower-case form. */
export const ALLOWED_AVATAR_MIMES = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export type AvatarMime = (typeof ALLOWED_AVATAR_MIMES)[number];
/**
* Sniff the MIME from the buffer's magic bytes. 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 — the sniff is the source of truth.
*
* Returns `null` for an unsupported / unrecognised format.
*/
export function detectAvatarMimeType(buffer: Buffer): AvatarMime | null {
if (buffer.length < 12) return null;
// JPEG — FF D8 FF
if (
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
) {
return "image/jpeg";
}
// PNG — 89 50 4E 47 0D 0A 1A 0A
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return "image/png";
}
// WebP — "RIFF" .... "WEBP"
if (
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
return null;
}
export interface AvatarDimensions {
width: number;
height: number;
}
/**
* Read the native image dimensions from the header bytes. Each
* format encodes width + height at a fixed offset in the leading
* chunks, so a full decode is unnecessary:
*
* - PNG: IHDR chunk after the 8-byte signature; width at offset
* 16, height at offset 20, both big-endian 32-bit unsigned.
* - JPEG: walk the marker segments until the first SOFn (Start
* Of Frame) marker, then read the two big-endian 16-bit values
* at marker offset + 5 (height) and + 7 (width).
* - WebP: three sub-formats (VP8 / VP8L / VP8X); each pins
* width-1 and height-1 at known offsets in the leading chunk.
*
* Returns `null` when the buffer is truncated or the format header
* is malformed; the caller maps that to a 422.
*/
export function readAvatarDimensions(
buffer: Buffer,
mime: AvatarMime,
): AvatarDimensions | null {
switch (mime) {
case "image/png":
return readPngDimensions(buffer);
case "image/jpeg":
return readJpegDimensions(buffer);
case "image/webp":
return readWebpDimensions(buffer);
}
}
function readPngDimensions(buffer: Buffer): AvatarDimensions | null {
// 8-byte signature + 4-byte chunk length + "IHDR" + 4-byte width
// + 4-byte height = first valid read at offset 16.
if (buffer.length < 24) return null;
if (buffer.toString("ascii", 12, 16) !== "IHDR") return null;
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function readJpegDimensions(buffer: Buffer): AvatarDimensions | null {
// SOI marker (FF D8) confirmed by `detectAvatarMimeType`. Walk
// the marker segments until the first SOFn (Start Of Frame)
// marker (FF C0 .. FF C3, FF C5 .. FF C7, FF C9 .. FF CB, FF CD ..
// FF CF). Every other marker carries a 16-bit length we can skip.
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null;
// Skip any fill bytes (FF FF ...)
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) return null;
const marker = buffer[offset];
offset += 1;
// Standalone markers without a payload.
if (marker === 0xd8 || marker === 0xd9) continue;
if (marker >= 0xd0 && marker <= 0xd7) continue;
if (offset + 7 >= buffer.length) return null;
const segmentLength = buffer.readUInt16BE(offset);
if (segmentLength < 2) return null;
const isSof =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isSof) {
// The SOF payload layout is:
// length (2) | precision (1) | height (2) | width (2) | ...
const height = buffer.readUInt16BE(offset + 3);
const width = buffer.readUInt16BE(offset + 5);
if (width <= 0 || height <= 0) return null;
return { width, height };
}
offset += segmentLength;
}
return null;
}
function readWebpDimensions(buffer: Buffer): AvatarDimensions | null {
// RIFF / WEBP confirmed by `detectAvatarMimeType`. The chunk at
// offset 12 is one of:
// "VP8 " — lossy
// "VP8L" — lossless
// "VP8X" — extended
if (buffer.length < 30) return null;
const fourCc = buffer.toString("ascii", 12, 16);
if (fourCc === "VP8 ") {
// VP8 frame header. Width/height live at chunk-payload offset
// 6 / 8 as 14-bit values (bottom 14 bits of a 16-bit field).
if (buffer.length < 30) return null;
const width = buffer.readUInt16LE(26) & 0x3fff;
const height = buffer.readUInt16LE(28) & 0x3fff;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
if (fourCc === "VP8L") {
// VP8L header: 1-byte signature (0x2f) + 4-byte packed dims.
// width-1 = 14 low bits of the 32-bit little-endian word;
// height-1 = the next 14 bits.
if (buffer.length < 25) return null;
if (buffer[20] !== 0x2f) return null;
const packed = buffer.readUInt32LE(21);
const width = (packed & 0x3fff) + 1;
const height = ((packed >> 14) & 0x3fff) + 1;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
if (fourCc === "VP8X") {
// VP8X chunk: 1-byte flags + 3-byte reserved + 3-byte canvas
// width-1 + 3-byte canvas height-1 (all little-endian, padded
// to 24 bits).
if (buffer.length < 30) return null;
const widthMinusOne =
buffer[24] | (buffer[25] << 8) | (buffer[26] << 16);
const heightMinusOne =
buffer[27] | (buffer[28] << 8) | (buffer[29] << 16);
const width = widthMinusOne + 1;
const height = heightMinusOne + 1;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
return null;
}
/**
* Build the cache-busting avatar URL the /me payload returns. The
* `?v={updatedAtMs}` suffix lets the client cache aggressively
* while keeping a re-upload visible on the next paint.
*/
export function buildAvatarUrl(userId: string, updatedAt: Date): string {
return `/api/user/avatar/${userId}?v=${updatedAt.getTime()}`;
}
@@ -87,7 +87,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
gravatarUrl: null,
avatarUrl: null,
glucoseUnit: null,
disableCoach,
};
-12
View File
@@ -1,12 +0,0 @@
import { createHash } from "crypto";
/**
* Generate a Gravatar avatar URL from an email address.
* Uses SHA-256 hash (supported by Gravatar since 2023).
* Returns 404 as default so the client can fall back to initials.
*/
export function getGravatarUrl(email: string, size = 80): string {
const trimmed = email.trim().toLowerCase();
const hash = createHash("sha256").update(trimmed).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`;
}
+95
View File
@@ -1484,6 +1484,101 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/user/avatar": {
post: {
tags: ["Auth"],
summary: "Upload the calling user's avatar",
description:
"Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048 dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from same-origin.",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: z.object({
file: z
.string()
.describe(
"Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.",
),
}),
},
},
},
responses: {
"201": {
description:
"Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches on re-upload.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
avatarUrl: z.string(),
contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
updatedAt: z.iso.datetime({ offset: true }),
}),
"UploadAvatarResponse",
),
},
},
},
"413": {
description:
"Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description:
"Image format is not one of JPEG / PNG / WebP (magic-byte sniff).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Auth"],
summary: "Clear the calling user's avatar",
description:
"Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no audit-log row.",
responses: {
"204": {
description: "Avatar cleared (or already empty).",
},
...stdResponses,
},
},
},
"/api/user/avatar/{id}": {
get: {
tags: ["Auth"],
summary: "Serve a user's avatar bytes",
description:
"Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403. Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends `?v={updatedAtMs}` so clients can cache aggressively.",
requestParams: {
path: z.object({
id: z.string().describe("User id (must match the calling user)."),
}),
},
responses: {
"200": {
description: "Avatar bytes.",
content: {
"image/jpeg": { schema: z.string().describe("Raw JPEG bytes.") },
"image/png": { schema: z.string().describe("Raw PNG bytes.") },
"image/webp": { schema: z.string().describe("Raw WebP bytes.") },
},
},
"403": {
description: "Caller is not the avatar's owner.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "The user has no uploaded avatar.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/chat/messages/{id}/feedback": {
post: {
tags: ["Insights"],
+8 -2
View File
@@ -265,9 +265,15 @@ export function proxy(request: NextRequest) {
const withingsConnectSrc = isWithingsRoute
? " https://wbsapi.withings.net"
: "";
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
// which leaked the email digest to Automattic on every authenticated
// page-load. Avatars now live on the User row and serve from
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
// them.
const csp = isDev
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
response.headers.set("Content-Security-Policy", csp);
// Production-only headers. HSTS carries `preload` so the domain stays
+164
View File
@@ -0,0 +1,164 @@
/**
* v1.5.5 — self-hosted avatar storage end-to-end guard.
*
* Confirms that the upload + read + delete cycle round-trips the raw
* image bytes through Postgres without truncation, encoding drift,
* or owner-scope leaks:
*
* 1. POST /api/user/avatar with a valid PNG returns 201 + the
* `avatarUrl` the /me payload would return.
* 2. GET /api/user/avatar/{userId} for the same session echoes the
* bytes verbatim with the persisted Content-Type.
* 3. GET /api/user/avatar/{otherUserId} from the first session
* returns 403 — the owner-scope gate covers cross-user reads.
* 4. DELETE /api/user/avatar wipes the columns and the subsequent
* GET returns 404.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
async function seedSessionUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
return { user, session };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("self-hosted avatar — end-to-end", () => {
it("round-trips the bytes through Postgres without drift", async () => {
const { user } = await seedSessionUser("avatar-owner");
cookieJar.set(
"healthlog_session",
(await getPrismaClient().session.findFirst({
where: { userId: user.id },
}))!.id,
);
const { POST, DELETE } = await import("@/app/api/user/avatar/route");
const { GET } = await import("@/app/api/user/avatar/[id]/route");
// Narrow the apiHandler signature for the test fixtures (see
// tests/integration/timezone-per-user.test.ts for the precedent).
type RouteCtx = { params: Promise<{ id: string }> };
const post = POST as (r: Request) => Promise<Response>;
const del = DELETE as (r: Request) => Promise<Response>;
const get = GET as (r: Request, ctx: RouteCtx) => Promise<Response>;
// ── POST: upload the 1×1 PNG ──────────────────────────────
const fd = new FormData();
fd.append(
"file",
new Blob([PNG_1X1], { type: "image/png" }),
"avatar.png",
);
const uploadReq = new Request("http://localhost/api/user/avatar", {
method: "POST",
body: fd,
});
const uploadRes = await post(uploadReq);
expect(uploadRes.status).toBe(201);
const uploadEnv = (await uploadRes.json()) as {
data: { avatarUrl: string; contentType: string };
};
expect(uploadEnv.data.contentType).toBe("image/png");
expect(uploadEnv.data.avatarUrl).toMatch(
new RegExp(`^/api/user/avatar/${user.id}\\?v=\\d+$`),
);
// ── GET (owner): bytes round-trip exactly ────────────────
const getRes = await get(
new Request(`http://localhost/api/user/avatar/${user.id}`),
{ params: Promise.resolve({ id: user.id }) },
);
expect(getRes.status).toBe(200);
expect(getRes.headers.get("content-type")).toBe("image/png");
const echoed = Buffer.from(await getRes.arrayBuffer());
expect(echoed.equals(PNG_1X1)).toBe(true);
// ── GET (cross-user): 403 ────────────────────────────────
const { user: other } = await seedSessionUser("other-user");
const crossRes = await get(
new Request(`http://localhost/api/user/avatar/${other.id}`),
{ params: Promise.resolve({ id: other.id }) },
);
expect(crossRes.status).toBe(403);
// ── DELETE: clears the row ───────────────────────────────
const delRes = await del(
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
);
expect(delRes.status).toBe(204);
const fresh = await getPrismaClient().user.findUnique({
where: { id: user.id },
select: {
avatarBytes: true,
avatarContentType: true,
avatarUpdatedAt: true,
},
});
expect(fresh?.avatarBytes).toBeNull();
expect(fresh?.avatarContentType).toBeNull();
expect(fresh?.avatarUpdatedAt).toBeNull();
// ── GET (after delete): 404 ──────────────────────────────
const post404 = await get(
new Request(`http://localhost/api/user/avatar/${user.id}`),
{ params: Promise.resolve({ id: user.id }) },
);
expect(post404.status).toBe(404);
});
});