mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] prevent money laundering
This commit is contained in:
@@ -131,6 +131,16 @@ export interface CustomAvatarItem {
|
||||
*/
|
||||
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
|
||||
|
||||
/**
|
||||
* The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and
|
||||
* the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the
|
||||
* client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party
|
||||
* items are not bound by it: their prices come off the storefront dump and several sit
|
||||
* above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made
|
||||
* rows, told apart by `BaseAvatarItemId`).
|
||||
*/
|
||||
export const MAX_PLAYER_ITEM_PRICE = 1000
|
||||
|
||||
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
|
||||
export interface CreateCustomAvatarItemInput {
|
||||
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
|
||||
|
||||
@@ -361,7 +361,7 @@ export const CheerPlayerResponse = z.object({
|
||||
export const CreateCustomAvatarItemMetadata = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string().optional(),
|
||||
Price: z.number().int().optional(),
|
||||
Price: z.number().int().min(0).max(1000).optional().describe('Tokens, 0–1000; 0 when left out'),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
|
||||
Accessibility: z.number().int().optional(),
|
||||
@@ -434,7 +434,7 @@ export const CustomAvatarItemDto = z.object({
|
||||
export const UpdateCustomAvatarItemRequest = z.object({
|
||||
Name: z.string().nullable().optional(),
|
||||
Description: z.string().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional(),
|
||||
Price: z.number().int().min(0).max(1000).nullable().optional().describe('Tokens, 0–1000'),
|
||||
Accessibility: z.number().int().nullable().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -42,9 +42,12 @@ import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
getCustomAvatarItem,
|
||||
importCustomAvatarItem,
|
||||
MAX_PLAYER_ITEM_PRICE,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
|
||||
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
@@ -4606,6 +4609,138 @@ describe('custom avatar items', () => {
|
||||
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
|
||||
})
|
||||
|
||||
// A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The
|
||||
// client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a
|
||||
// player has enforce it — refusing, not clamping, so the creator sees what was set.
|
||||
test('the price is capped at 1000 tokens on create and edit', async () => {
|
||||
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
|
||||
const create = async (Price: unknown) => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 't.png', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'd.png', { type: 'image/png' }))
|
||||
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
const over = await create(1001)
|
||||
expect(over.status).toBe(400)
|
||||
expect(await over.json()).toMatchObject({
|
||||
Success: false,
|
||||
Value: null,
|
||||
Error: 'Price must be <= 1000',
|
||||
})
|
||||
expect((await create(-1)).status).toBe(400)
|
||||
expect((await create(10.5)).status).toBe(400)
|
||||
// Nothing was written for the refused ones.
|
||||
expect(
|
||||
await env.DB.prepare(`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`)
|
||||
.first<{ n: number }>()
|
||||
.then((r) => r!.n)
|
||||
).toBe(0)
|
||||
|
||||
// The cap itself is allowed, inclusive; a missing price is still 0.
|
||||
const atCap = await create(1000)
|
||||
expect(atCap.status).toBe(200)
|
||||
const { Value } = (await atCap.json()) as { Value: { CustomAvatarItemId: string; Price: number } }
|
||||
expect(Value.Price).toBe(1000)
|
||||
expect(((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price).toBe(0)
|
||||
|
||||
// The edit is bound the same way, and a refused edit leaves the row as it was.
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
|
||||
const edit = async (Price: number) =>
|
||||
exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
|
||||
})
|
||||
const tooMuch = await edit(1001)
|
||||
expect(tooMuch.status).toBe(400)
|
||||
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' })
|
||||
expect((await edit(-1)).status).toBe(400)
|
||||
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
|
||||
const lowered = await edit(999)
|
||||
expect(lowered.status).toBe(200)
|
||||
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999)
|
||||
})
|
||||
|
||||
// The cap arrived after players had already priced shirts. Migration 0032 lowers every
|
||||
// PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the
|
||||
// storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`.
|
||||
// Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE
|
||||
// is run here by hand.
|
||||
test('migration 0032 lowers existing player-made prices to the cap', async () => {
|
||||
const shirt = (name: string, price: number) =>
|
||||
createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 1,
|
||||
designFilename: 'd',
|
||||
thumbnailImageFilename: 't',
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const dear = await shirt('Dear', 5000)
|
||||
const fair = await shirt('Fair', 1000)
|
||||
const cheap = await shirt('Cheap', 50)
|
||||
// A first-party item over the cap: no base item, Coach-authored.
|
||||
const firstPartyId = crypto.randomUUID()
|
||||
await importCustomAvatarItem(env.DB, {
|
||||
CustomAvatarItemId: firstPartyId,
|
||||
RankedEntityId: firstPartyId,
|
||||
CreatorAccountId: 1,
|
||||
Name: 'Studio Wings',
|
||||
Description: null,
|
||||
Price: 6000,
|
||||
Accessibility: 1,
|
||||
OutfitType: 100,
|
||||
BaseAvatarItemId: null,
|
||||
BaseAvatarItemColor: null,
|
||||
DesignFilename: null,
|
||||
ThumbnailImageFilename: null,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: true,
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||
ModifiedAt: '2025-02-19T05:19:28.328Z',
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
})
|
||||
|
||||
const statements = priceCapSql
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((sql) => sql.trim())
|
||||
.filter(Boolean)
|
||||
expect(statements).toHaveLength(1)
|
||||
await env.DB.prepare(statements[0]!).run()
|
||||
|
||||
const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))!
|
||||
expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000)
|
||||
// The lowered row is marked modified, as an edit would mark it; the untouched ones aren't.
|
||||
expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt)
|
||||
expect(await price(fair.CustomAvatarItemId)).toMatchObject({ Price: 1000, ModifiedAt: fair.ModifiedAt })
|
||||
expect(await price(cheap.CustomAvatarItemId)).toMatchObject({ Price: 50, ModifiedAt: cheap.ModifiedAt })
|
||||
expect((await price(firstPartyId)).Price).toBe(6000)
|
||||
})
|
||||
|
||||
test('DELETE removes the creator’s item and its bucket objects', async () => {
|
||||
// Create through the endpoint so the objects really exist in the bucket.
|
||||
const form = new FormData()
|
||||
|
||||
Reference in New Issue
Block a user