@@ -42,6 +42,7 @@ const RANDOM_START_ROW = Number(RANDOM_AREA.startRow) || 1;
4242const RANDOM_END_ROW = Number ( RANDOM_AREA . endRow ) || 8 ;
4343const RANDOM_START_COLUMN = Number ( RANDOM_AREA . startColumn ) || 4 ;
4444const RANDOM_END_COLUMN = Number ( RANDOM_AREA . endColumn ) || 16 ;
45+ const CATBOX_FILE_HOST = 'files.catbox.moe' ;
4546
4647function requireBlobConfig ( ) {
4748 if ( ! process . env . BLOB_READ_WRITE_TOKEN ) {
@@ -118,6 +119,65 @@ function parseImageDataUrl(imageDataUrl) {
118119 } ;
119120}
120121
122+ function normalizeRemoteImageUrl ( value ) {
123+ if ( typeof value !== 'string' || ! value . trim ( ) ) {
124+ return '' ;
125+ }
126+
127+ let parsedUrl ;
128+ try {
129+ parsedUrl = new URL ( value . trim ( ) ) ;
130+ } catch ( error ) {
131+ const invalidUrlError = new Error ( 'Commission image URL is invalid' ) ;
132+ invalidUrlError . statusCode = 400 ;
133+ throw invalidUrlError ;
134+ }
135+
136+ if (
137+ parsedUrl . protocol !== 'https:' ||
138+ parsedUrl . hostname . toLowerCase ( ) !== CATBOX_FILE_HOST
139+ ) {
140+ const invalidHostError = new Error (
141+ 'Commission image must be a files.catbox.moe URL' ,
142+ ) ;
143+ invalidHostError . statusCode = 400 ;
144+ throw invalidHostError ;
145+ }
146+
147+ return parsedUrl . toString ( ) ;
148+ }
149+
150+ function normalizeRemoteMimeType ( contentType , imageUrl ) {
151+ const mimeType = String ( contentType || '' )
152+ . split ( ';' ) [ 0 ]
153+ . trim ( )
154+ . toLowerCase ( ) ;
155+
156+ if (
157+ mimeType === 'image/png' ||
158+ mimeType === 'image/jpeg' ||
159+ mimeType === 'image/webp'
160+ ) {
161+ return mimeType ;
162+ }
163+
164+ if ( / \. p n g (?: $ | \? ) / i. test ( imageUrl ) ) {
165+ return 'image/png' ;
166+ }
167+
168+ if ( / \. ( j p e ? g | j f i f ) (?: $ | \? ) / i. test ( imageUrl ) ) {
169+ return 'image/jpeg' ;
170+ }
171+
172+ if ( / \. w e b p (?: $ | \? ) / i. test ( imageUrl ) ) {
173+ return 'image/webp' ;
174+ }
175+
176+ const error = new Error ( 'Commission image must be a PNG, JPG, or WEBP' ) ;
177+ error . statusCode = 400 ;
178+ throw error ;
179+ }
180+
121181function parseImageDimensions ( buffer , mimeType ) {
122182 if ( mimeType === 'image/png' ) {
123183 const pngSignature = '89504e470d0a1a0a' ;
@@ -133,6 +193,43 @@ function parseImageDimensions(buffer, mimeType) {
133193 } ;
134194 }
135195
196+ if ( mimeType === 'image/webp' ) {
197+ const riffHeader = buffer . subarray ( 0 , 4 ) . toString ( 'ascii' ) ;
198+ const webpHeader = buffer . subarray ( 8 , 12 ) . toString ( 'ascii' ) ;
199+ if ( riffHeader !== 'RIFF' || webpHeader !== 'WEBP' ) {
200+ const error = new Error ( 'Invalid WEBP image' ) ;
201+ error . statusCode = 400 ;
202+ throw error ;
203+ }
204+
205+ const chunkHeader = buffer . subarray ( 12 , 16 ) . toString ( 'ascii' ) ;
206+ if ( chunkHeader === 'VP8X' && buffer . length >= 30 ) {
207+ return {
208+ width : 1 + buffer . readUIntLE ( 24 , 3 ) ,
209+ height : 1 + buffer . readUIntLE ( 27 , 3 ) ,
210+ } ;
211+ }
212+
213+ if ( chunkHeader === 'VP8 ' && buffer . length >= 30 ) {
214+ return {
215+ width : buffer . readUInt16LE ( 26 ) & 0x3fff ,
216+ height : buffer . readUInt16LE ( 28 ) & 0x3fff ,
217+ } ;
218+ }
219+
220+ if ( chunkHeader === 'VP8L' && buffer . length >= 25 ) {
221+ const bits = buffer . readUInt32LE ( 21 ) ;
222+ return {
223+ width : ( bits & 0x3fff ) + 1 ,
224+ height : ( ( bits >> 14 ) & 0x3fff ) + 1 ,
225+ } ;
226+ }
227+
228+ const error = new Error ( 'Invalid WEBP image' ) ;
229+ error . statusCode = 400 ;
230+ throw error ;
231+ }
232+
136233 if ( mimeType !== 'image/jpeg' ) {
137234 const error = new Error ( 'Unsupported image type' ) ;
138235 error . statusCode = 400 ;
@@ -281,15 +378,18 @@ function chooseRandomGridIndex(entries, currentId = '') {
281378}
282379
283380function buildPublicCommission ( record ) {
381+ const publicImageUrl =
382+ record . imageUrl ||
383+ `/api/commissions/image?id=${ encodeURIComponent ( record . id ) } ` ;
284384 return {
285385 id : record . id ,
286386 artistName : record . artistName ,
287387 artistLink : record . artistLink ,
288388 date : record . date ,
289389 description : record . description ,
290390 gridIndex : record . gridIndex ,
291- imageUrl : `/api/commissions/image?id= ${ encodeURIComponent ( record . id ) } ` ,
292- iconUrl : `/api/commissions/image?id= ${ encodeURIComponent ( record . id ) } ` ,
391+ imageUrl : publicImageUrl ,
392+ iconUrl : publicImageUrl ,
293393 mimeType : record . mimeType ,
294394 width : record . width ,
295395 height : record . height ,
@@ -309,6 +409,49 @@ async function getCommission(id) {
309409 return entries . find ( entry => entry . id === id ) || null ;
310410}
311411
412+ async function loadCommissionImage ( input , existing ) {
413+ if ( input . imageUrl ) {
414+ const imageUrl = normalizeRemoteImageUrl ( input . imageUrl ) ;
415+ const response = await fetch ( imageUrl ) ;
416+
417+ if ( ! response . ok ) {
418+ const error = new Error ( 'Failed to fetch commission image from Catbox' ) ;
419+ error . statusCode = 400 ;
420+ throw error ;
421+ }
422+
423+ const mimeType = normalizeRemoteMimeType (
424+ response . headers . get ( 'content-type' ) ,
425+ imageUrl ,
426+ ) ;
427+ const buffer = Buffer . from ( await response . arrayBuffer ( ) ) ;
428+
429+ return {
430+ imageUrl,
431+ mimeType,
432+ buffer,
433+ source : 'catbox' ,
434+ } ;
435+ }
436+
437+ if ( input . imageDataUrl ) {
438+ const imageInfo = parseImageDataUrl ( input . imageDataUrl ) ;
439+ return {
440+ ...imageInfo ,
441+ imageUrl : '' ,
442+ source : 'blob' ,
443+ } ;
444+ }
445+
446+ if ( ! existing ) {
447+ const error = new Error ( 'Commission image is required' ) ;
448+ error . statusCode = 400 ;
449+ throw error ;
450+ }
451+
452+ return null ;
453+ }
454+
312455async function upsertCommission ( input ) {
313456 const artistName = sanitizeLine ( input . artistName , 80 ) ;
314457 const artistLink = normalizeArtistLink ( input . artistLink ) ;
@@ -339,49 +482,48 @@ async function upsertCommission(input) {
339482
340483 validateGridIndex ( gridIndex , entries , existing ? existing . id : '' ) ;
341484
342- let imageInfo = null ;
343- if ( input . imageDataUrl ) {
344- imageInfo = parseImageDataUrl ( input . imageDataUrl ) ;
345- } else if ( ! existing ) {
346- const error = new Error ( 'Commission image is required' ) ;
347- error . statusCode = 400 ;
348- throw error ;
349- }
485+ const imageInfo = await loadCommissionImage ( input , existing ) ;
350486
351487 const updatedAt = createIsoStamp ( ) ;
352488
353489 if ( ! existing ) {
354490 const id = createCommissionId ( ) ;
355- const fileExtension = imageInfo . mimeType === 'image/png' ? 'png' : 'jpg' ;
356- const assetPath = `commissions/assets/${ id } .${ fileExtension } ` ;
357491 const { width, height } = parseImageDimensions (
358492 imageInfo . buffer ,
359493 imageInfo . mimeType ,
360494 ) ;
361495
362- await put ( assetPath , imageInfo . buffer , {
363- access : 'private' ,
364- addRandomSuffix : false ,
365- allowOverwrite : true ,
366- contentType : imageInfo . mimeType ,
367- cacheControlMaxAge : 0 ,
368- } ) ;
369-
370496 const record = {
371497 id,
372498 artistName,
373499 artistLink,
374500 date,
375501 description,
376502 gridIndex,
377- assetPath,
503+ imageUrl : imageInfo . imageUrl || '' ,
504+ assetPath : '' ,
378505 mimeType : imageInfo . mimeType ,
379506 width,
380507 height,
381508 createdAt : updatedAt ,
382509 updatedAt,
383510 } ;
384511
512+ if ( imageInfo . source === 'blob' ) {
513+ const fileExtension = imageInfo . mimeType === 'image/png' ? 'png' : 'jpg' ;
514+ const assetPath = `commissions/assets/${ id } .${ fileExtension } ` ;
515+
516+ await put ( assetPath , imageInfo . buffer , {
517+ access : 'private' ,
518+ addRandomSuffix : false ,
519+ allowOverwrite : true ,
520+ contentType : imageInfo . mimeType ,
521+ cacheControlMaxAge : 0 ,
522+ } ) ;
523+
524+ record . assetPath = assetPath ;
525+ }
526+
385527 await saveCommissionsManifest ( sortCommissions ( [ record , ...entries ] ) ) ;
386528 return record ;
387529 }
@@ -398,21 +540,26 @@ async function upsertCommission(input) {
398540 imageInfo . buffer ,
399541 imageInfo . mimeType ,
400542 ) ;
401- const fileExtension = imageInfo . mimeType === 'image/png' ? 'png' : 'jpg' ;
402- const assetPath = `commissions/assets/${ existing . id } .${ fileExtension } ` ;
403-
404- await put ( assetPath , imageInfo . buffer , {
405- access : 'private' ,
406- addRandomSuffix : false ,
407- allowOverwrite : true ,
408- contentType : imageInfo . mimeType ,
409- cacheControlMaxAge : 0 ,
410- } ) ;
543+ let assetPath = '' ;
544+
545+ if ( imageInfo . source === 'blob' ) {
546+ const fileExtension = imageInfo . mimeType === 'image/png' ? 'png' : 'jpg' ;
547+ assetPath = `commissions/assets/${ existing . id } .${ fileExtension } ` ;
548+
549+ await put ( assetPath , imageInfo . buffer , {
550+ access : 'private' ,
551+ addRandomSuffix : false ,
552+ allowOverwrite : true ,
553+ contentType : imageInfo . mimeType ,
554+ cacheControlMaxAge : 0 ,
555+ } ) ;
556+ }
411557
412558 if ( existing . assetPath && existing . assetPath !== assetPath ) {
413559 await del ( existing . assetPath ) . catch ( ( ) => undefined ) ;
414560 }
415561
562+ existing . imageUrl = imageInfo . imageUrl || '' ;
416563 existing . assetPath = assetPath ;
417564 existing . mimeType = imageInfo . mimeType ;
418565 existing . width = width ;
0 commit comments