Skip to content

Commit 1d2724a

Browse files
committed
Custom color on avatar api
1 parent 967cf51 commit 1d2724a

1 file changed

Lines changed: 47 additions & 42 deletions

File tree

  • pages/api/workspace/[id]/avatar/[userid]

pages/api/workspace/[id]/avatar/[userid]/index.ts

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import noblox from 'noblox.js';
66
import { createHash } from 'crypto';
77
import sharp from 'sharp';
88

9-
type CacheEntry = {
10-
buffer: Buffer;
11-
etag: string;
12-
mtime: number;
9+
type CacheEntry = {
10+
buffer: Buffer;
11+
etag: string;
12+
mtime: number;
1313
lastRefresh: number;
1414
metadata: { color?: string; resolution: number };
1515
};
@@ -47,7 +47,7 @@ const BG_COLORS: Record<string, string> = {
4747

4848
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
4949
const { userid, color, res: resParam } = req.query;
50-
50+
5151
if (!userid || Array.isArray(userid)) return res.status(400).end('Invalid userId');
5252
if (!/^[0-9]+$/.test(userid)) return res.status(400).end('Invalid userId');
5353
const userIdNum = Number(userid);
@@ -66,7 +66,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
6666

6767
let sourceResolution: number;
6868
let fetchFromRoblox: boolean;
69-
69+
7070
if (ROBLOX_RESOLUTIONS.includes(resolution)) {
7171
sourceResolution = resolution;
7272
fetchFromRoblox = true;
@@ -83,16 +83,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
8383
const colorLower = color.toLowerCase();
8484
if (BG_COLORS[colorLower]) {
8585
bgColor = colorLower;
86+
} else if (/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(colorLower)) {
87+
bgColor = colorLower.startsWith('#') ? colorLower : `#${colorLower}`;
8688
} else {
87-
return res.status(400).end('Invalid color (supported: blue, purple, green, red, orange, yellow, pink, gray, black, white)');
89+
return res.status(400).end('Invalid color (use a preset name or a hex like #fff or #aabbcc)');
8890
}
8991
}
9092

9193
const avatarDir = path.join(process.cwd(), 'public', 'avatars');
9294
const baseFileName = `${userIdNum}_${sourceResolution}.png`;
9395
const avatarPath = path.join(avatarDir, baseFileName);
9496
const resolved = path.resolve(avatarPath);
95-
97+
9698
if (!resolved.startsWith(path.resolve(avatarDir) + path.sep)) {
9799
return res.status(400).end('Invalid userId');
98100
}
@@ -109,33 +111,33 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
109111
setCommonHeaders(res, mem);
110112
res.setHeader('Content-Length', mem.buffer.length.toString());
111113
res.end(mem.buffer);
112-
114+
113115
if (Date.now() - mem.lastRefresh > STALE_AFTER_MS) {
114-
triggerBackgroundRefresh(userIdNum, avatarPath, cacheKey, bgColor, resolution).catch(() => {});
116+
triggerBackgroundRefresh(userIdNum, avatarPath, cacheKey, bgColor, resolution).catch(() => { });
115117
}
116118
return;
117119
}
118120

119-
await fs.mkdir(avatarDir, { recursive: true }).catch(() => {});
121+
await fs.mkdir(avatarDir, { recursive: true }).catch(() => { });
120122

121123
let baseBuffer: Buffer | null = null;
122124
let diskStat: any = null;
123-
125+
124126
try {
125127
baseBuffer = await fs.readFile(avatarPath);
126128
diskStat = await fs.stat(avatarPath);
127-
} catch {}
129+
} catch { }
128130

129131
if (fetchFromRoblox || !baseBuffer || (diskStat && Date.now() - diskStat.mtimeMs > STALE_AFTER_MS)) {
130132
baseBuffer = await fetchAndPersist(userIdNum, avatarPath, sourceResolution);
131133
diskStat = { mtimeMs: Date.now() };
132134
}
133135

134136
const needsProcessing = resolution !== sourceResolution || bgColor;
135-
const processedBuffer = needsProcessing
137+
const processedBuffer = needsProcessing
136138
? await processImage(baseBuffer, bgColor, resolution, sourceResolution)
137139
: baseBuffer;
138-
140+
139141
const etag = computeETag(processedBuffer);
140142
const now = Date.now();
141143
const entry: CacheEntry = {
@@ -145,27 +147,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
145147
lastRefresh: now,
146148
metadata: { color: bgColor, resolution }
147149
};
148-
150+
149151
touch(cacheKey, entry);
150-
152+
151153
if (isNotModified(req, entry)) {
152154
setCommonHeaders(res, entry);
153155
return res.status(304).end();
154156
}
155-
157+
156158
setCommonHeaders(res, entry);
157159
res.setHeader('Content-Length', processedBuffer.length.toString());
158160
res.end(processedBuffer);
159-
161+
160162
} catch (e) {
161163
console.error('Avatar error serving', userIdNum, e);
162164
res.status(404).end('Not found');
163165
}
164166
}
165167

166168
async function processImage(
167-
buffer: Buffer,
168-
bgColor?: string,
169+
buffer: Buffer,
170+
bgColor?: string,
169171
targetResolution: number = 180,
170172
sourceResolution: number = 180
171173
): Promise<Buffer> {
@@ -179,11 +181,10 @@ async function processImage(
179181
});
180182
}
181183

182-
// Add background color if specified
183-
if (bgColor && BG_COLORS[bgColor]) {
184-
const hexColor = BG_COLORS[bgColor];
184+
if (bgColor) {
185+
const hexColor = BG_COLORS[bgColor] ?? bgColor; // use preset or raw hex
185186
const rgb = hexToRgb(hexColor);
186-
187+
187188
pipeline = pipeline.flatten({
188189
background: rgb
189190
});
@@ -193,7 +194,11 @@ async function processImage(
193194
}
194195

195196
function hexToRgb(hex: string): { r: number; g: number; b: number } {
196-
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
197+
let normalized = hex.replace(/^#/, '');
198+
if (normalized.length === 3) {
199+
normalized = normalized.split('').map(c => c + c).join('');
200+
}
201+
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized);
197202
return result ? {
198203
r: parseInt(result[1], 16),
199204
g: parseInt(result[2], 16),
@@ -215,34 +220,34 @@ function setCommonHeaders(res: NextApiResponse, entry: CacheEntry) {
215220
function isNotModified(req: NextApiRequest, entry: CacheEntry): boolean {
216221
const inm = req.headers['if-none-match'];
217222
if (inm && inm === entry.etag) return true;
218-
223+
219224
const ims = req.headers['if-modified-since'];
220225
if (ims) {
221226
const since = Date.parse(ims);
222227
if (!Number.isNaN(since) && entry.mtime <= since) return true;
223228
}
224-
229+
225230
return false;
226231
}
227232

228233
async function fetchAndPersist(userId: number, filePath: string, resolution: number = 180): Promise<Buffer> {
229234
const remoteUrl = await getRemoteAvatarUrl(userId, resolution);
230-
const response = await axios.get(remoteUrl, {
231-
responseType: 'arraybuffer',
232-
timeout: 12000
235+
const response = await axios.get(remoteUrl, {
236+
responseType: 'arraybuffer',
237+
timeout: 12000
233238
});
234239
const buf = Buffer.from(response.data);
235-
240+
236241
if (ROBLOX_RESOLUTIONS.includes(resolution)) {
237-
fs.writeFile(filePath, buf).catch(() => {});
242+
fs.writeFile(filePath, buf).catch(() => { });
238243
}
239-
244+
240245
return buf;
241246
}
242247

243248
async function triggerBackgroundRefresh(
244-
userId: number,
245-
filePath: string,
249+
userId: number,
250+
filePath: string,
246251
cacheKey: string,
247252
bgColor?: string,
248253
targetResolution: number = 180
@@ -255,14 +260,14 @@ async function triggerBackgroundRefresh(
255260
} else {
256261
sourceResolution = 720;
257262
}
258-
263+
259264
const baseBuffer = await fetchAndPersist(userId, filePath, sourceResolution);
260-
265+
261266
const needsProcessing = targetResolution !== sourceResolution || bgColor;
262267
const processedBuffer = needsProcessing
263268
? await processImage(baseBuffer, bgColor, targetResolution, sourceResolution)
264269
: baseBuffer;
265-
270+
266271
const now = Date.now();
267272
const entry: CacheEntry = {
268273
buffer: processedBuffer,
@@ -271,7 +276,7 @@ async function triggerBackgroundRefresh(
271276
lastRefresh: now,
272277
metadata: { color: bgColor, resolution: targetResolution }
273278
};
274-
279+
275280
touch(cacheKey, entry);
276281
console.log('Avatar refreshed', userId, `(${targetResolution}x${targetResolution}${bgColor ? `, ${bgColor}` : ''})`);
277282
} catch (e) {
@@ -283,7 +288,7 @@ async function getRemoteAvatarUrl(userid: number, resolution: number = 180): Pro
283288
try {
284289
const thumbnails = await noblox.getPlayerThumbnail([userid], resolution as any, 'png', false, 'headshot');
285290
if (thumbnails && thumbnails[0]?.imageUrl) return thumbnails[0].imageUrl;
286-
} catch {}
287-
291+
} catch { }
292+
288293
return `https://www.roblox.com/headshot-thumbnail/image?userId=${userid}&width=${resolution}&height=${resolution}&format=png`;
289294
}

0 commit comments

Comments
 (0)