@@ -6,10 +6,10 @@ import noblox from 'noblox.js';
66import { createHash } from 'crypto' ;
77import 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
4848export 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 - 9 a - f ] { 3 } | [ 0 - 9 a - 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
166168async 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
195196function 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) {
215220function 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
228233async 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
243248async 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