[api] prevent money laundering

This commit is contained in:
Devin Zuczek
2026-09-27 23:54:12 -04:00
parent ab15eb9768
commit 83ccb4bc12
5 changed files with 186 additions and 6 deletions
+21 -4
View File
@@ -23,6 +23,7 @@ import {
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
@@ -473,12 +474,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'which is what the store’s user-generated-content tab searches for.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
'0, is refused with a 400 rather than clamped.',
security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: {
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
400: json(
CustomAvatarItemResponse,
'Missing or malformed metadata / files, or a `Price` outside 0–1000'
),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
@@ -504,6 +510,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (meta.Price !== undefined && meta.Price !== null) {
if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price))
return fail('Price must be an integer')
if (meta.Price < 0) return fail('Price must be >= 0')
if (meta.Price > MAX_PLAYER_ITEM_PRICE)
return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
}
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env)
@@ -572,13 +585,14 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
'sends every field and nulls the ones it is not changing, so null means "leave ' +
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' +
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: {
200: json(CustomAvatarItemResponse, 'The updated item'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
@@ -621,6 +635,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0')
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')