avatar.ts
Raw
1import { mkdirSync } from "node:fs";
2import path from "node:path";
3import { encode } from "@jsquash/jxl";
4import { fileTypeFromBuffer } from "file-type";
5import sharp from "sharp";
6import { paths } from "../constants.ts";
7
8mkdirSync(paths.AVATARS_DIR, { recursive: true });
9
10// FNV-1a: produces n deterministic bytes from a string
11function hashBytes(s: string, n: number): number[] {
12 const out: number[] = [];
13 let h = 0x811c9dc5;
14 for (const c of s) {
15 h ^= c.charCodeAt(0);
16 h = Math.imul(h, 0x01000193) >>> 0;
17 }
18 while (out.length < n) {
19 h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0;
20 out.push(
21 h & 0xff,
22 (h >>> 8) & 0xff,
23 (h >>> 16) & 0xff,
24 (h >>> 24) & 0xff,
25 );
26 }
27 return out.slice(0, n);
28}
29
30function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31 s /= 100;
32 l /= 100;
33 const a = s * Math.min(l, 1 - l);
34 const f = (n: number) => {
35 const k = (n + h / 30) % 12;
36 return Math.round(
37 (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255,
38 );
39 };
40 return [f(0), f(8), f(4)];
41}
42
43export function avatarJxlPath(userId: number): string {
44 return path.join(paths.AVATARS_DIR, `${userId}.jxl`);
45}
46
47export async function processAndStoreAvatar(
48 userId: number,
49 buffer: Buffer,
50): Promise<void> {
51 const type = await fileTypeFromBuffer(buffer);
52 if (!type?.mime.startsWith("image/")) {
53 throw new Error("Invalid image type");
54 }
55 // Cap input pixels to defeat decompression bombs: a 2 MB
56 // PNG/WebP can claim 16k×16k = ~256 MP and force sharp to allocate
57 // ~1 GB of RGBA before the resize step. 4096*4096 = 16 MP is well
58 // above any plausible avatar source (the output is 128×128).
59 const { data, info } = await sharp(buffer, {
60 limitInputPixels: 4096 * 4096,
61 })
62 .resize(128, 128, { fit: "cover", position: "center" })
63 .ensureAlpha()
64 .raw()
65 .toBuffer({ resolveWithObject: true });
66
67 const jxl = await encode(
68 {
69 data: new Uint8ClampedArray(
70 data.buffer,
71 data.byteOffset,
72 data.byteLength,
73 ),
74 width: info.width,
75 height: info.height,
76 },
77 {
78 progressive: true,
79 quality: 90,
80 effort: 9,
81 },
82 );
83 await Bun.write(avatarJxlPath(userId), jxl);
84}
85
86export async function createDefaultAvatar(
87 userId: number,
88 username: string,
89): Promise<void> {
90 const b = hashBytes(username, 16);
91
92 const hue = (b[0]! | (b[1]! << 8)) % 360;
93 const sat = (b[2]! % 20) + 65; // 65–84%
94 const lit = (b[3]! % 20) + 40; // 40–59%
95 const [fr, fg, fb] = hslToRgb(hue, sat, lit);
96
97 // 128×128, background #f0f0f0, 5×5 symmetric identicon
98 // PADDING=24, CELL=16: 2×24 + 5×16 = 128
99 const SIZE = 128,
100 PADDING = 24,
101 CELL = 16;
102 const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255);
103 for (let i = 0; i < SIZE * SIZE * 4; i += 4) {
104 pixels[i] = 240;
105 pixels[i + 1] = 240;
106 pixels[i + 2] = 240;
107 }
108
109 // 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0)
110 for (let row = 0; row < 5; row++) {
111 for (let col = 0; col < 5; col++) {
112 const srcCol = col < 3 ? col : 4 - col;
113 if ((b[row * 3 + srcCol]! & 1) === 0) continue;
114 const x0 = PADDING + col * CELL;
115 const y0 = PADDING + row * CELL;
116 for (let y = y0; y < y0 + CELL; y++) {
117 for (let x = x0; x < x0 + CELL; x++) {
118 const i = (y * SIZE + x) * 4;
119 pixels[i] = fr;
120 pixels[i + 1] = fg;
121 pixels[i + 2] = fb;
122 }
123 }
124 }
125 }
126
127 const jxl = await encode({ data: pixels, width: SIZE, height: SIZE });
128 await Bun.write(avatarJxlPath(userId), jxl);
129}
130