diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0e4bec1..7d459d279 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/prisma/migrations/0085_v155_user_avatar/migration.sql b/prisma/migrations/0085_v155_user_avatar/migration.sql new file mode 100644 index 000000000..e3b97485e --- /dev/null +++ b/prisma/migrations/0085_v155_user_avatar/migration.sql @@ -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); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 00d0ba227..949490a22 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 diff --git a/src/app/api/user/avatar/[id]/__tests__/route.test.ts b/src/app/api/user/avatar/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..0cb2596a0 --- /dev/null +++ b/src/app/api/user/avatar/[id]/__tests__/route.test.ts @@ -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; + +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); + }); +}); diff --git a/src/app/api/user/avatar/[id]/route.ts b/src/app/api/user/avatar/[id]/route.ts new file mode 100644 index 000000000..d4922d007 --- /dev/null +++ b/src/app/api/user/avatar/[id]/route.ts @@ -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", + }, + }); +}); diff --git a/src/app/api/user/avatar/__tests__/route.test.ts b/src/app/api/user/avatar/__tests__/route.test.ts new file mode 100644 index 000000000..bbaf6d761 --- /dev/null +++ b/src/app/api/user/avatar/__tests__/route.test.ts @@ -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`; 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; +const del = DELETE as (r: Request) => Promise; + +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` + // 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(); + }); +}); diff --git a/src/app/api/user/avatar/route.ts b/src/app/api/user/avatar/route.ts new file mode 100644 index 000000000..1aca9de97 --- /dev/null +++ b/src/app/api/user/avatar/route.ts @@ -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`; 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 }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index ba69fbb34..e837401bd 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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("", () => { 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("", () => { const html = render(); expect(html).toContain('data-slot="coach-bubble-user-avatar"'); expect(html).toMatch( - /]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/, + /]+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(); expect(html).toContain('data-slot="coach-bubble-user-avatar"'); expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE {content} - {gravatarUrl ? ( + {avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element ({ @@ -89,7 +89,7 @@ describe(" admin entry mirrors Settings (no sub-item expansion)", () // sub-items on `/admin/*` was unwanted UX — the in-shell `` // 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" }); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 0d5a530a7..42e22116e 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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 }) { >
- {gravatarUrl && } + {avatarUrl && } {getInitials(user.username)} @@ -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]" > - {gravatarUrl && ( - + {avatarUrl && ( + )} {getInitials(user.username)} @@ -216,7 +216,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
- {gravatarUrl && } + {avatarUrl && } {getInitials(user.username)} diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index a437b5ced..3bc018a41 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -53,7 +53,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, - gravatarUrl: null, + avatarUrl: null, glucoseUnit: null, disableCoach, }; diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index be7275659..a28b25e51 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -21,7 +21,13 @@ export interface AuthUser { * `` 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 diff --git a/src/lib/__tests__/avatar.test.ts b/src/lib/__tests__/avatar.test.ts new file mode 100644 index 000000000..1569b2357 --- /dev/null +++ b/src/lib/__tests__/avatar.test.ts @@ -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); + }); +}); diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 000000000..458afc47a --- /dev/null +++ b/src/lib/avatar.ts @@ -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()}`; +} diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index dd465cec7..76d5e7224 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -87,7 +87,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, - gravatarUrl: null, + avatarUrl: null, glucoseUnit: null, disableCoach, }; diff --git a/src/lib/gravatar.ts b/src/lib/gravatar.ts deleted file mode 100644 index 6834dce9b..000000000 --- a/src/lib/gravatar.ts +++ /dev/null @@ -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`; -} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 4bb205992..2592c5c08 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1484,6 +1484,101 @@ export const openApiPaths: NonNullable = { }, }, }, + "/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"], diff --git a/src/proxy.ts b/src/proxy.ts index 6a8311d27..5a1f8cf95 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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/`, + // 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 diff --git a/tests/integration/user-avatar.test.ts b/tests/integration/user-avatar.test.ts new file mode 100644 index 000000000..28c761d9c --- /dev/null +++ b/tests/integration/user-avatar.test.ts @@ -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; + const del = DELETE as (r: Request) => Promise; + const get = GET as (r: Request, ctx: RouteCtx) => Promise; + + // ── 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); + }); +});