Skip to content

Commit 00af01a

Browse files
committed
feat: add commission draft library and catbox uploads
1 parent dae7a06 commit 00af01a

17 files changed

Lines changed: 432 additions & 61 deletions

api/_lib/commissions-store.js

Lines changed: 178 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const RANDOM_START_ROW = Number(RANDOM_AREA.startRow) || 1;
4242
const RANDOM_END_ROW = Number(RANDOM_AREA.endRow) || 8;
4343
const RANDOM_START_COLUMN = Number(RANDOM_AREA.startColumn) || 4;
4444
const RANDOM_END_COLUMN = Number(RANDOM_AREA.endColumn) || 16;
45+
const CATBOX_FILE_HOST = 'files.catbox.moe';
4546

4647
function 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 (/\.png(?:$|\?)/i.test(imageUrl)) {
165+
return 'image/png';
166+
}
167+
168+
if (/\.(jpe?g|jfif)(?:$|\?)/i.test(imageUrl)) {
169+
return 'image/jpeg';
170+
}
171+
172+
if (/\.webp(?:$|\?)/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+
121181
function 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

283380
function 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+
312455
async 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;

api/_lib/handlers.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,7 @@ function assertSubmissionRateLimit(req, res) {
5959
);
6060

6161
if (activeEntries.length >= SUBMISSION_RATE_LIMIT_MAX) {
62-
sendError(
63-
res,
64-
429,
65-
'Too many submissions from this IP. Try again later.',
66-
);
62+
sendError(res, 429, 'Too many submissions from this IP. Try again later.');
6763
return false;
6864
}
6965

@@ -134,6 +130,8 @@ async function handleListPublicDrawings(req, res) {
134130
async function handleListPublicCommissions(req, res) {
135131
if (!assertMethod(req, res, ['GET'])) return;
136132

133+
noStore(res);
134+
137135
try {
138136
sendJson(res, 200, {
139137
commissions: await getPublicCommissions(),
192 KB
Loading
155 KB
Loading
68.6 KB
Loading
294 KB
Binary file not shown.
262 KB
Binary file not shown.
1.18 MB
Loading
9.71 MB
Loading
238 KB
Loading

0 commit comments

Comments
 (0)