Files
HealthLog/src/lib/avatar.ts
T
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb 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>
2026-05-28 22:39:20 +02:00

219 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()}`;
}