Files
HealthLog/src/lib/avatar.ts
T
Marc-André Bombeck ba22932101 fix(documents): read a JPEG's size past fill bytes and refuse an unreadable one
The thumbnail's header walk took the byte after any 0xFF as a marker. A
single 0xFF fill byte after the start-of-image marker, which the JPEG
standard allows and every decoder skips, threw it off; it read no size, and
an unread size let the image through to a decode that allocates the full
bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the
web process before the post-decode check could see it.

The avatar upload already had a walker that skips fill bytes. Both now use
one reader (lib/image-dimensions) that follows the marker rules: fill bytes,
the length-less markers, and no size when a scan or the end of the image
comes before a frame header. The thumbnail treats an unreadable size as a
refusal (size-unreadable) for every format it decodes, and the backfill
counts that refusal as final.
2026-10-25 13:52:04 +01:00

91 lines
2.8 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 lives in `./image-dimensions` and covers JPEG / PNG / WebP.
*/
import { readImageDimensions, type ImageDimensions } from "./image-dimensions";
/** 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 type AvatarDimensions = ImageDimensions;
/**
* Read the native image dimensions from the header bytes, without a
* decode (`readImageDimensions`, shared with the document thumbnail).
* 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 {
return readImageDimensions(buffer, mime);
}
/**
* 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()}`;
}