mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api,econ,img] shirts fix #35
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- Custom avatar items (the player-designed shirts/hats built on a base catalog item).
|
||||
-- Owned by the `api` worker: `POST /api/customAvatarItems/v1` inserts a row. Generated
|
||||
-- from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One column per field of the client's `CustomAvatarItem` DTO, so a row IS the response.
|
||||
-- The two uploads (the design and the thumbnail PNG) live in the `recflare-img` bucket
|
||||
-- under `avatar-item/<date>/<id>-thumb.png` / `<id>-design.png`; the filename columns
|
||||
-- hold those bucket keys.
|
||||
--
|
||||
-- `ranking_context` and `purchase_info` are served as null and `current_saves` as an
|
||||
-- empty list; none of them has a source yet, so they are not columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
||||
base_avatar_item_id INTEGER NOT NULL,
|
||||
base_avatar_item_color TEXT NOT NULL,
|
||||
design_filename TEXT NOT NULL,
|
||||
thumbnail_image_filename TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Custom avatar items — player-designed items built on a base catalog item — on the
|
||||
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
|
||||
* DTO, so a row maps straight onto the response.
|
||||
*
|
||||
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
|
||||
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
|
||||
* those bucket keys, which the `img` worker serves back by key.
|
||||
*
|
||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
|
||||
* applied under its own `migrations_table`).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
||||
base_avatar_item_id INTEGER NOT NULL,
|
||||
base_avatar_item_color TEXT NOT NULL,
|
||||
design_filename TEXT NOT NULL,
|
||||
thumbnail_image_filename TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
]
|
||||
|
||||
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
|
||||
export interface CustomAvatarItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
Accessibility: number
|
||||
ForceCannotPublish: boolean
|
||||
IsFeatured: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
BaseAvatarItemId: number
|
||||
BaseAvatarItemColor: string
|
||||
DesignFilename: string
|
||||
ThumbnailImageFilename: string
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
PreviewOrientation: number
|
||||
RankingContext: null
|
||||
OutfitType: number
|
||||
CurrentSaves: never[]
|
||||
PurchaseInfo: null
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
customAvatarItemId: string
|
||||
creatorAccountId: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
baseAvatarItemId: number
|
||||
baseAvatarItemColor: string
|
||||
accessibility: number
|
||||
designFilename: string
|
||||
thumbnailImageFilename: string
|
||||
}
|
||||
|
||||
interface Row {
|
||||
custom_avatar_item_id: string
|
||||
creator_account_id: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
accessibility: number
|
||||
force_cannot_publish: number
|
||||
is_featured: number
|
||||
is_rec_room_approved: number
|
||||
base_avatar_item_id: number
|
||||
base_avatar_item_color: string
|
||||
design_filename: string
|
||||
thumbnail_image_filename: string
|
||||
created_at: string
|
||||
modified_at: string
|
||||
preview_orientation: number
|
||||
outfit_type: number
|
||||
}
|
||||
|
||||
function toDto(row: Row): CustomAvatarItem {
|
||||
return {
|
||||
CustomAvatarItemId: row.custom_avatar_item_id,
|
||||
CreatorAccountId: row.creator_account_id,
|
||||
Name: row.name,
|
||||
Description: row.description,
|
||||
Price: row.price,
|
||||
Accessibility: row.accessibility,
|
||||
ForceCannotPublish: row.force_cannot_publish === 1,
|
||||
IsFeatured: row.is_featured === 1,
|
||||
IsRecRoomApproved: row.is_rec_room_approved === 1,
|
||||
BaseAvatarItemId: row.base_avatar_item_id,
|
||||
BaseAvatarItemColor: row.base_avatar_item_color,
|
||||
DesignFilename: row.design_filename,
|
||||
ThumbnailImageFilename: row.thumbnail_image_filename,
|
||||
CreatedAt: row.created_at,
|
||||
ModifiedAt: row.modified_at,
|
||||
PreviewOrientation: row.preview_orientation,
|
||||
RankingContext: null,
|
||||
OutfitType: row.outfit_type,
|
||||
CurrentSaves: [],
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts a new custom avatar item and returns it as the client's DTO. */
|
||||
export async function createCustomAvatarItem(
|
||||
db: D1Database,
|
||||
input: CreateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem> {
|
||||
const ts = now.toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO custom_avatar_item (
|
||||
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
|
||||
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
|
||||
created_at, modified_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.customAvatarItemId,
|
||||
input.creatorAccountId,
|
||||
input.name,
|
||||
input.description,
|
||||
input.price,
|
||||
input.accessibility,
|
||||
input.baseAvatarItemId,
|
||||
input.baseAvatarItemColor,
|
||||
input.designFilename,
|
||||
input.thumbnailImageFilename,
|
||||
ts
|
||||
)
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
||||
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
||||
*/
|
||||
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
|
||||
|
||||
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
|
||||
export interface UgcPurchasableItem {
|
||||
ItemType: number
|
||||
ItemId: string
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
RoomId: number
|
||||
Price: number
|
||||
PurchaseCurrencyId: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
||||
* request — the item table has no room; what the client wants it for is still unknown.
|
||||
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
||||
*/
|
||||
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
||||
return {
|
||||
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||
ItemId: item.CustomAvatarItemId,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
ImageName: item.ThumbnailImageFilename,
|
||||
RoomId: roomId,
|
||||
Price: item.Price,
|
||||
PurchaseCurrencyId: null,
|
||||
CreatedAt: item.CreatedAt,
|
||||
ModifiedAt: item.ModifiedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
|
||||
export async function getCustomAvatarItems(
|
||||
db: D1Database,
|
||||
ids: string[]
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<Row>()
|
||||
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
|
||||
* feed is empty until an operator writes `is_featured = 1`.
|
||||
*/
|
||||
export async function listFeaturedCustomAvatarItems(
|
||||
db: D1Database,
|
||||
limit = 50
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
.all<Row>()
|
||||
return results.map(toDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
|
||||
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
|
||||
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
|
||||
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
|
||||
* always equals the list length.
|
||||
*/
|
||||
export async function listCustomAvatarItemsByCreator(
|
||||
db: D1Database,
|
||||
creatorAccountId: number,
|
||||
includeUnpublished = false
|
||||
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item
|
||||
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
||||
ORDER BY created_at DESC, custom_avatar_item_id`
|
||||
)
|
||||
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
|
||||
.all<Row>()
|
||||
const items = results.map(toDto)
|
||||
return { Results: items, TotalResults: items.length }
|
||||
}
|
||||
|
||||
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
|
||||
export interface UpdateCustomAvatarItemInput {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
price?: number | null
|
||||
accessibility?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
|
||||
* null keep their value (the client sends every field, nulling the untouched ones).
|
||||
* Returns the updated item, or null when no row has that id.
|
||||
*/
|
||||
export async function updateCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
patch: UpdateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET
|
||||
name = COALESCE(?2, name),
|
||||
description = COALESCE(?3, description),
|
||||
price = COALESCE(?4, price),
|
||||
accessibility = COALESCE(?5, accessibility),
|
||||
modified_at = ?6
|
||||
WHERE custom_avatar_item_id = ?1
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
patch.name ?? null,
|
||||
patch.description ?? null,
|
||||
patch.price ?? null,
|
||||
patch.accessibility ?? null,
|
||||
now.toISOString()
|
||||
)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
|
||||
export async function deleteCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Fetches one item by id, or null. */
|
||||
export async function getCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
+74
-2
@@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/**
|
||||
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
|
||||
* that serve public data but show more to a known caller (a creator's own unpublished
|
||||
* custom avatar items) instead of 401ing.
|
||||
*/
|
||||
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
|
||||
|
||||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||||
@@ -91,6 +98,9 @@ export const JsonArray = z.array(z.unknown())
|
||||
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
|
||||
export const BareInteger = z.number().int()
|
||||
|
||||
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
|
||||
export const BareString = z.string()
|
||||
|
||||
@@ -312,7 +322,9 @@ export const CheerPlayerRequest = z.object({
|
||||
export const SetSelectedCheerRequest = z.object({
|
||||
CheerCategory: z
|
||||
.string()
|
||||
.describe('The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'),
|
||||
.describe(
|
||||
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -326,6 +338,66 @@ export const CheerPlayerResponse = z.object({
|
||||
Message: z.string().nullable().describe('Null when the cheer landed'),
|
||||
})
|
||||
|
||||
/** The `metadata` JSON field of a custom-avatar-item creation. */
|
||||
export const CreateCustomAvatarItemMetadata = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string().optional(),
|
||||
Price: z.number().int().optional(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
|
||||
Accessibility: z.number().int().optional(),
|
||||
})
|
||||
|
||||
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
|
||||
export const CreateCustomAvatarItemRequest = z.object({
|
||||
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
|
||||
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
|
||||
design: z.string().describe('The design blob (binary file part)'),
|
||||
})
|
||||
|
||||
/** The client's `CustomAvatarItem` record. */
|
||||
export const CustomAvatarItemDto = z.object({
|
||||
CustomAvatarItemId: z.string(),
|
||||
CreatorAccountId: z.number().int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
Price: z.number().int(),
|
||||
Accessibility: z.number().int(),
|
||||
ForceCannotPublish: z.boolean(),
|
||||
IsFeatured: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string(),
|
||||
DesignFilename: z.string(),
|
||||
ThumbnailImageFilename: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
PreviewOrientation: z.number().int(),
|
||||
RankingContext: z.null(),
|
||||
OutfitType: z.number().int(),
|
||||
CurrentSaves: z.array(z.unknown()),
|
||||
PurchaseInfo: z.null(),
|
||||
})
|
||||
|
||||
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
|
||||
export const UpdateCustomAvatarItemRequest = z.object({
|
||||
Name: z.string().nullable().optional(),
|
||||
Description: z.string().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional(),
|
||||
Accessibility: z.number().int().nullable().optional(),
|
||||
})
|
||||
|
||||
/** A bare list of custom avatar items (the featured feed). */
|
||||
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
|
||||
|
||||
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
|
||||
export const CustomAvatarItemResponse = z.object({
|
||||
Value: CustomAvatarItemDto.nullable(),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
error_id: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** The `Ids` form body the bulk POST endpoints take. */
|
||||
export const BulkIdsRequest = z.object({
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
@@ -507,7 +579,7 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
||||
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: JsonArray,
|
||||
Results: CustomAvatarItemList,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
|
||||
+238
-10
@@ -12,6 +12,14 @@ import {
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
deleteCustomAvatarItem,
|
||||
getCustomAvatarItem,
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
updateCustomAvatarItem,
|
||||
} from '../custom-avatar-items-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
@@ -36,7 +44,11 @@ import {
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
BareInteger,
|
||||
BulkCustomAvatarItemsRequest,
|
||||
CreateCustomAvatarItemRequest,
|
||||
CustomAvatarItemList,
|
||||
CustomAvatarItemResponse,
|
||||
CustomAvatarItemsPage,
|
||||
ErrorResponse,
|
||||
form,
|
||||
@@ -53,6 +65,7 @@ import {
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OPTIONAL_AUTHED,
|
||||
OutfitSaveResponse,
|
||||
OutfitsBulkRequest,
|
||||
OutfitsBulkResponse,
|
||||
@@ -62,10 +75,12 @@ import {
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
SetTagsResponse,
|
||||
stringParam,
|
||||
stringQuery,
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
|
||||
@@ -227,17 +242,208 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(true)
|
||||
)
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Minimum token price for a public custom item',
|
||||
description:
|
||||
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
|
||||
responses: { 200: json(BareInteger, 'A bare `100`') },
|
||||
}),
|
||||
(c) => c.json(100)
|
||||
)
|
||||
.post(
|
||||
'/api/customAvatarItems/v1',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Create a custom avatar item',
|
||||
description:
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.\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.',
|
||||
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'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// The featured custom-avatar-item feed. No curated items yet → an empty list.
|
||||
const fail = (message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
if (typeof body.metadata !== 'string') return fail('metadata is required')
|
||||
let meta: Record<string, unknown>
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.metadata)
|
||||
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
|
||||
meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return fail('metadata is not valid JSON')
|
||||
}
|
||||
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
|
||||
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
|
||||
if (typeof meta.BaseAvatarItemColor !== 'string')
|
||||
return fail('BaseAvatarItemColor is required')
|
||||
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
||||
if (!(body.design instanceof File)) return fail('design is required')
|
||||
|
||||
// Both files go to the shared image bucket, foldered by upload date and keyed by
|
||||
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||
// them back by key.
|
||||
const customAvatarItemId = crypto.randomUUID()
|
||||
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
|
||||
const thumbnailImageFilename = `${prefix}-thumb.png`
|
||||
const designFilename = `${prefix}-design.png`
|
||||
await Promise.all([
|
||||
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
|
||||
}),
|
||||
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.design.type || 'image/png' },
|
||||
}),
|
||||
])
|
||||
|
||||
const item = await createCustomAvatarItem(c.env.DB, {
|
||||
customAvatarItemId,
|
||||
creatorAccountId: id,
|
||||
name: meta.Name,
|
||||
description: typeof meta.Description === 'string' ? meta.Description : '',
|
||||
price: typeof meta.Price === 'number' ? meta.Price : 0,
|
||||
baseAvatarItemId: meta.BaseAvatarItemId,
|
||||
baseAvatarItemColor: meta.BaseAvatarItemColor,
|
||||
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
|
||||
designFilename,
|
||||
thumbnailImageFilename,
|
||||
})
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Edit a custom avatar item',
|
||||
description:
|
||||
'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.',
|
||||
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'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 400 | 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body) return fail(400, 'A JSON body is required')
|
||||
const str = (v: unknown, field: string): string | null | undefined => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
|
||||
return v
|
||||
}
|
||||
const int = (v: unknown, field: string): number | null => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'number' || !Number.isInteger(v))
|
||||
throw new TypeError(`${field} must be an integer`)
|
||||
return v
|
||||
}
|
||||
let patch
|
||||
try {
|
||||
patch = {
|
||||
name: str(body.Name, 'Name'),
|
||||
description: str(body.Description, 'Description'),
|
||||
price: int(body.Price, 'Price'),
|
||||
accessibility: int(body.Accessibility, 'Accessibility'),
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(400, (e as Error).message)
|
||||
}
|
||||
if (patch.name !== null && patch.name?.trim() === '')
|
||||
return fail(400, 'Name must not be blank')
|
||||
|
||||
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Delete a custom avatar item',
|
||||
description:
|
||||
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
|
||||
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The deleted item'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
// The row is gone; the objects follow. A missing key is a no-op for R2.
|
||||
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
|
||||
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Featured custom avatar items',
|
||||
description: 'The curated feed. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
description:
|
||||
'The curated feed: items with `IsFeatured` set that are also published ' +
|
||||
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
|
||||
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
|
||||
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||||
)
|
||||
|
||||
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
|
||||
@@ -288,20 +494,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
|
||||
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
|
||||
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
|
||||
.get(
|
||||
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'A creator’s custom avatar items',
|
||||
description:
|
||||
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
|
||||
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
|
||||
'The items an account has authored, newest first, in the same page shape as the ' +
|
||||
'`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' +
|
||||
'bearer token is the creator’s, in which case their unpublished (`Accessibility` 0) ' +
|
||||
'items are included too. Paging is not applied (the client sends none), so ' +
|
||||
'`TotalResults` is the length of `Results`.',
|
||||
security: OPTIONAL_AUTHED,
|
||||
parameters: [idParam('accountId', 'Creator account id')],
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'The creator’s items') },
|
||||
}),
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const viewer = await authedId(c)
|
||||
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
@@ -984,6 +1199,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
|
||||
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
|
||||
.get(
|
||||
'/api/inventions/v1/featureddormskins',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured dorm-skin feed',
|
||||
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
|
||||
// shelf, and the "from creators you follow" row, from.
|
||||
//
|
||||
|
||||
@@ -30,6 +30,10 @@ import '../../api.app'
|
||||
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
@@ -148,6 +152,9 @@ beforeAll(async () => {
|
||||
|
||||
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
|
||||
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Custom avatar items (owned by the api worker).
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -516,7 +523,12 @@ describe('public endpoints', () => {
|
||||
expect(allAnonymous[0]).toMatchObject({
|
||||
playerId: 7109,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: { FromPlayerId: 0, ToPlayerId: 7109, Type: MessageType.PlayerCheerAnonymous, Data: '10' },
|
||||
data: {
|
||||
FromPlayerId: 0,
|
||||
ToPlayerId: 7109,
|
||||
Type: MessageType.PlayerCheerAnonymous,
|
||||
Data: '10',
|
||||
},
|
||||
})
|
||||
const anonymous = allAnonymous.slice(1)
|
||||
expect(anonymous[0]).toMatchObject({
|
||||
@@ -571,7 +583,11 @@ describe('public endpoints', () => {
|
||||
// reputation — all durable and all addressed: nothing was broadcast.
|
||||
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
|
||||
expect(frames.some((f) => f.ephemeral)).toBe(false)
|
||||
expect(frames[0]!.data).toMatchObject({ ToPlayerId: 7131, Type: MessageType.PlayerCheer, Data: '40' })
|
||||
expect(frames[0]!.data).toMatchObject({
|
||||
ToPlayerId: 7131,
|
||||
Type: MessageType.PlayerCheer,
|
||||
Data: '40',
|
||||
})
|
||||
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
|
||||
})
|
||||
|
||||
@@ -607,7 +623,9 @@ describe('public endpoints', () => {
|
||||
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
|
||||
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
|
||||
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: CheerCategory.GreatHost })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({
|
||||
SelectedCheer: CheerCategory.GreatHost,
|
||||
})
|
||||
|
||||
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
|
||||
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
|
||||
@@ -616,7 +634,7 @@ describe('public endpoints', () => {
|
||||
Success: false,
|
||||
Message: 'CheerCategory is not a cheer category',
|
||||
})
|
||||
expect((await pin('0', '7140').then((r) => r.status))).toBe(200)
|
||||
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
|
||||
@@ -936,9 +954,56 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
|
||||
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const older = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Older', 1),
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const newer = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Newer', 1),
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
|
||||
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
||||
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
||||
await env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
|
||||
)
|
||||
.bind(unflagged.CustomAvatarItemId)
|
||||
.run()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
|
||||
expect(res.status).toBe(200)
|
||||
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
newer.CustomAvatarItemId,
|
||||
older.CustomAvatarItemId,
|
||||
])
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
|
||||
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
|
||||
|
||||
function item(name: string, accessibility: number) {
|
||||
return {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -948,10 +1013,70 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const base = {
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
const pub = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Published',
|
||||
accessibility: 1,
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const draft = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Draft',
|
||||
accessibility: 0,
|
||||
},
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
await createCustomAvatarItem(env.DB, {
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 9,
|
||||
name: 'Other',
|
||||
accessibility: 0,
|
||||
})
|
||||
|
||||
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
|
||||
|
||||
// Anonymous, or someone else: only the published (Accessibility != 0) item.
|
||||
for (const headers of [{}, await bearer('9')]) {
|
||||
const res = await exports.default.fetch(url, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const page = (await res.json()) as Page
|
||||
expect(page.TotalResults).toBe(1)
|
||||
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
|
||||
}
|
||||
|
||||
// The creator: their unpublished item too, newest first.
|
||||
const own = (await (
|
||||
await exports.default.fetch(url, { headers: await bearer('205') })
|
||||
).json()) as Page
|
||||
expect(own.TotalResults).toBe(2)
|
||||
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
draft.CustomAvatarItemId,
|
||||
pub.CustomAvatarItemId,
|
||||
])
|
||||
|
||||
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
// Nothing locks avatar items here, so the array is empty and the posted ids are never
|
||||
@@ -2354,6 +2479,231 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom avatar items', () => {
|
||||
test('minPriceForPublicItem is a bare 100', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(100)
|
||||
})
|
||||
|
||||
test('POST creates an item from the multipart form and returns it', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
Accessibility: 0,
|
||||
})
|
||||
)
|
||||
form.set(
|
||||
'thumbnailImage',
|
||||
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
|
||||
)
|
||||
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
error_id: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Error).toBeNull()
|
||||
expect(body.error_id).toBeNull()
|
||||
expect(body.Value).toMatchObject({
|
||||
CreatorAccountId: 205,
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
Accessibility: 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
OutfitType: 0,
|
||||
CurrentSaves: [],
|
||||
PurchaseInfo: null,
|
||||
})
|
||||
const itemId = body.Value.CustomAvatarItemId as string
|
||||
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
||||
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
||||
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
|
||||
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
|
||||
|
||||
// Both uploads landed in the image bucket under those keys.
|
||||
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
|
||||
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
|
||||
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
|
||||
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([4, 5, 6])
|
||||
)
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
|
||||
)
|
||||
.bind(body.Value.CustomAvatarItemId)
|
||||
.first()
|
||||
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
|
||||
})
|
||||
|
||||
test('PUT edits the creator’s item, leaving nulled fields alone', async () => {
|
||||
const item = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Visor',
|
||||
description: 'shiny',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 0,
|
||||
designFilename: 'd',
|
||||
thumbnailImageFilename: 't',
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
|
||||
const res = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Value).toMatchObject({
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
Name: 'Visor',
|
||||
Description: 'shiny',
|
||||
Price: 100,
|
||||
Accessibility: 1,
|
||||
CreatedAt: '2026-08-01T00:00:00.000Z',
|
||||
})
|
||||
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
|
||||
|
||||
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
|
||||
const other = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
})
|
||||
expect(other.status).toBe(403)
|
||||
const missing = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
}
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
const bad = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 'lots' }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
|
||||
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
|
||||
})
|
||||
|
||||
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()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
|
||||
const created = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
).json()) as {
|
||||
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
|
||||
}
|
||||
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
|
||||
|
||||
// Not the creator → 403 and nothing changes.
|
||||
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
|
||||
expect(other.status).toBe(403)
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
|
||||
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { CustomAvatarItemId, Name: 'Gone' },
|
||||
})
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
|
||||
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
|
||||
expect(
|
||||
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(CustomAvatarItemId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
|
||||
// Gone now → 404; no token → 401.
|
||||
const again = await exports.default.fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('205'),
|
||||
})
|
||||
expect(again.status).toBe(404)
|
||||
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST 400s without the files', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ Success: false, Value: null })
|
||||
})
|
||||
|
||||
test('POST 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
@@ -5269,6 +5619,7 @@ describe('openapi', () => {
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/customAvatarItems/v1/{id}',
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
@@ -5286,6 +5637,7 @@ describe('openapi', () => {
|
||||
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||||
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
||||
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
||||
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gameconfigs/v1/all',
|
||||
@@ -5301,6 +5653,7 @@ describe('openapi', () => {
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/featureddormskins',
|
||||
'GET /api/inventions/v1/fromcreators',
|
||||
'GET /api/inventions/v1/fulllineageowner',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
@@ -5369,6 +5722,7 @@ describe('openapi', () => {
|
||||
'POST /api/avatar/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
@@ -5409,6 +5763,7 @@ describe('openapi', () => {
|
||||
'POST /api/v1/progression/bulk',
|
||||
'POST /outfits/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
'PUT /api/customAvatarItems/v1/{id}',
|
||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||
'PUT /api/playerevents/v2/{eventId}/description',
|
||||
'PUT /api/playerevents/v2/{eventId}/name',
|
||||
|
||||
@@ -21,6 +21,13 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||
// their own, and buyInvention has to read the very rows `api` writes.
|
||||
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
|
||||
// lookup is the store's view of those rows.
|
||||
import {
|
||||
getCustomAvatarItems,
|
||||
toUgcPurchasable,
|
||||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||
} from '../../api/src/custom-avatar-items-db'
|
||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
||||
// The notification-type ids the hub carries, and the payload shapes recovered from the
|
||||
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
|
||||
@@ -92,6 +99,8 @@ import {
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
UgcPurchasableBulkRequest,
|
||||
UgcPurchasableItemList,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateObjectiveRequest,
|
||||
UpdateObjectiveResponse,
|
||||
@@ -2216,6 +2225,46 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
|
||||
// (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table.
|
||||
.post(
|
||||
'/api/ugcPurchasables/v1/items/bulk',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Look up UGC purchasables by id',
|
||||
description:
|
||||
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' +
|
||||
'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' +
|
||||
'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' +
|
||||
'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' +
|
||||
'not yet known. `PurchaseCurrencyId` is null until a currency exists.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'),
|
||||
responses: {
|
||||
200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'),
|
||||
400: json(ErrorResponse, 'Malformed body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
|
||||
const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0
|
||||
const ids = (body.Ids as unknown[]).flatMap((ref) => {
|
||||
if (!ref || typeof ref !== 'object') return []
|
||||
const { itemType, itemId } = ref as Record<string, unknown>
|
||||
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
|
||||
? [itemId]
|
||||
: []
|
||||
})
|
||||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||||
return c.json(items.map((item) => toUgcPurchasable(item, roomId)))
|
||||
}
|
||||
)
|
||||
|
||||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
||||
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||||
|
||||
@@ -401,6 +401,34 @@ export const BuyInventionResponse = z.object({
|
||||
})
|
||||
|
||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||||
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
|
||||
export const UgcPurchasableBulkRequest = z.object({
|
||||
RoomId: z.number().int().describe('Echoed back on each item; not otherwise used'),
|
||||
Ids: z.array(
|
||||
z.object({
|
||||
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
|
||||
itemId: z.string().describe('The `CustomAvatarItemId`'),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
/** The client's `UgcPurchasableItem` — a store-facing view of a custom avatar item. */
|
||||
export const UgcPurchasableItemDto = z.object({
|
||||
ItemType: z.number().int(),
|
||||
ItemId: z.string(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
RoomId: z.number().int(),
|
||||
Price: z.number().int(),
|
||||
PurchaseCurrencyId: z.string().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
})
|
||||
|
||||
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
|
||||
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
|
||||
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
|
||||
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
||||
// is built here too (see the same cross-worker import in econ.app.ts).
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
} from '../../../../api/src/custom-avatar-items-db'
|
||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||
// The notification-type ids the hub carries, from the worker that owns them — asserting
|
||||
// against the enum rather than a copied number is what keeps these frames honest.
|
||||
@@ -69,6 +73,7 @@ beforeAll(async () => {
|
||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||
.run()
|
||||
@@ -675,6 +680,63 @@ describe('econ endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => {
|
||||
const item = await createCustomAvatarItem(env.DB, {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Neon Visor',
|
||||
description: '',
|
||||
price: 250,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 0,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
})
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
RoomId: 92,
|
||||
Ids: [
|
||||
{ itemType: 3, itemId: item.CustomAvatarItemId },
|
||||
{ itemType: 3, itemId: 'does-not-exist' },
|
||||
{ itemType: 1, itemId: item.CustomAvatarItemId },
|
||||
],
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
ItemType: 3,
|
||||
ItemId: item.CustomAvatarItemId,
|
||||
Name: 'Neon Visor',
|
||||
Description: '',
|
||||
ImageName: 'thumb_x.png',
|
||||
RoomId: 92,
|
||||
Price: 250,
|
||||
PurchaseCurrencyId: null,
|
||||
CreatedAt: item.CreatedAt,
|
||||
ModifiedAt: item.ModifiedAt,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/ugcPurchasables/v1/items/bulk 400s without Ids and 401s without a token', async () => {
|
||||
const bad = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ RoomId: 92 }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ RoomId: 92, Ids: [] }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
|
||||
expect(anon.status).toBe(401)
|
||||
@@ -2868,6 +2930,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
'POST /api/objectives/v1/updateobjective',
|
||||
'POST /api/storefronts/v2/buyItem',
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
'PUT /api/equipment/v1/update',
|
||||
])
|
||||
|
||||
|
||||
+34
-8
@@ -36,6 +36,18 @@ const ALLOWED_DIMENSIONS = new Set([128, 256, 512, 1024])
|
||||
/** JPEG quality used when re-encoding a resized image. */
|
||||
const RESIZE_JPEG_QUALITY = 90
|
||||
|
||||
/** The eight-byte PNG signature. */
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
|
||||
/**
|
||||
* Whether these bytes are a PNG, by signature rather than the stored content-type: the
|
||||
* type on an R2 object is whatever the uploader claimed, and a custom avatar item's
|
||||
* design is posted under a `.bin` filename.
|
||||
*/
|
||||
function isPng(bytes: Uint8Array): boolean {
|
||||
return PNG_SIGNATURE.every((b, i) => bytes[i] === b)
|
||||
}
|
||||
|
||||
/** A requested transform, from `?width=`/`?height=`/`?cropSquare=1`. At least one applies. */
|
||||
interface Transform {
|
||||
width?: number
|
||||
@@ -65,13 +77,22 @@ function parseTransform(
|
||||
return { width: w, height: h, cropSquare: square }
|
||||
}
|
||||
|
||||
/** A transformed image and the type it was encoded as. */
|
||||
interface Transformed {
|
||||
bytes: Uint8Array
|
||||
contentType: 'image/png' | 'image/jpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode `input`, apply the requested transform (optional center-crop to a
|
||||
* square, then resize preserving aspect ratio when only one dimension is given),
|
||||
* and re-encode as JPEG. Runs the Photon WASM codec in-isolate; edge caching
|
||||
* and re-encode. A PNG source stays PNG — JPEG has no alpha channel, and flattening
|
||||
* a transparent design onto black is what broke custom avatar items in-game;
|
||||
* anything else becomes JPEG. Runs the Photon WASM codec in-isolate; edge caching
|
||||
* (see `wrangler.jsonc`) means each variant only pays this cost once.
|
||||
*/
|
||||
function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
||||
function resizeImage(input: Uint8Array, transform: Transform): Transformed {
|
||||
const png = isPng(input)
|
||||
let img = PhotonImage.new_from_byteslice(input)
|
||||
// Every PhotonImage we allocate (source + each stage) must be freed.
|
||||
const owned = [img]
|
||||
@@ -99,7 +120,9 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
||||
owned.push(img)
|
||||
}
|
||||
|
||||
return img.get_bytes_jpeg(RESIZE_JPEG_QUALITY)
|
||||
return png
|
||||
? { bytes: img.get_bytes(), contentType: 'image/png' }
|
||||
: { bytes: img.get_bytes_jpeg(RESIZE_JPEG_QUALITY), contentType: 'image/jpeg' }
|
||||
} finally {
|
||||
for (const image of owned) image.free()
|
||||
}
|
||||
@@ -238,9 +261,11 @@ async function finalizeImage(
|
||||
): Promise<Response> {
|
||||
let body: BufferSource = bytes
|
||||
if (transform) {
|
||||
body = resizeImage(new Uint8Array(bytes), transform)
|
||||
// Output is always JPEG, and the source etag no longer describes the body.
|
||||
headers.set('content-type', 'image/jpeg')
|
||||
const out = resizeImage(new Uint8Array(bytes), transform)
|
||||
body = out.bytes
|
||||
// The output is re-encoded (PNG stays PNG, everything else is JPEG), and the
|
||||
// source etag no longer describes the body.
|
||||
headers.set('content-type', out.contentType)
|
||||
headers.delete('etag')
|
||||
}
|
||||
|
||||
@@ -370,8 +395,9 @@ app.get(
|
||||
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||
'image is never rewritten in place, a new image gets a new key.',
|
||||
'',
|
||||
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and always',
|
||||
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and',
|
||||
're-encode it — a PNG source stays PNG (alpha preserved), anything else becomes JPEG —',
|
||||
'with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
||||
'ignored and the original is served — never an error.',
|
||||
'',
|
||||
|
||||
@@ -28,7 +28,8 @@ export function json(schema: z.ZodType, description: string) {
|
||||
/**
|
||||
* An image-bytes response. The stored object's own content type is served verbatim
|
||||
* (usually `image/jpeg`, occasionally `image/png`); any response that went through the
|
||||
* Photon resize/crop path is re-encoded and is always `image/jpeg`.
|
||||
* Photon resize/crop path is re-encoded: `image/png` when the source is a PNG (so alpha
|
||||
* survives), `image/jpeg` otherwise.
|
||||
*/
|
||||
export function imageBytes(description: string): OpenAPIV3_1.ResponseObject {
|
||||
const schema: OpenAPIV3_1.SchemaObject = { type: 'string', format: 'binary' }
|
||||
|
||||
@@ -344,6 +344,45 @@ describe('img endpoints', () => {
|
||||
expect(size.height).toBe(256)
|
||||
})
|
||||
|
||||
it('keeps a PNG source as PNG when resizing, alpha intact', async () => {
|
||||
// A 64×64 RGBA PNG: left half opaque red, right half fully transparent. Built
|
||||
// through Photon so the bytes are a real PNG with an alpha channel.
|
||||
const side = 64
|
||||
const rgba = new Uint8Array(side * side * 4)
|
||||
for (let y = 0; y < side; y++) {
|
||||
for (let x = 0; x < side; x++) {
|
||||
const i = (y * side + x) * 4
|
||||
rgba[i] = 255
|
||||
rgba[i + 3] = x < side / 2 ? 255 : 0
|
||||
}
|
||||
}
|
||||
const src = new PhotonImage(rgba, side, side)
|
||||
const pngBytes = src.get_bytes()
|
||||
src.free()
|
||||
// Stored under a `.bin` name with a claimed PNG type, like a custom avatar item's
|
||||
// design; the codec must go by the bytes, not the name.
|
||||
await env.IMAGES.put('avatar-item/2026-08-26/abc-design.bin', pngBytes, {
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/avatar-item/2026-08-26/abc-design.bin?width=128`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/png')
|
||||
expect(res.headers.get('etag')).toBeNull()
|
||||
|
||||
const out = PhotonImage.new_from_byteslice(new Uint8Array(await res.arrayBuffer()))
|
||||
try {
|
||||
expect(out.get_width()).toBe(128)
|
||||
const px = out.get_raw_pixels()
|
||||
const alphaAt = (x: number, y: number) => px[(y * 128 + x) * 4 + 3]
|
||||
// Deep inside each half, away from the Lanczos edge: opaque left, transparent right.
|
||||
expect(alphaAt(16, 64)).toBe(255)
|
||||
expect(alphaAt(112, 64)).toBe(0)
|
||||
} finally {
|
||||
out.free()
|
||||
}
|
||||
})
|
||||
|
||||
it('crops to a square at native size with ?cropSquare=1 alone', async () => {
|
||||
const full = jpegSize(
|
||||
new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
||||
|
||||
Generated
+3
@@ -672,6 +672,9 @@ importers:
|
||||
'@repo/hono-helpers':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/hono-helpers
|
||||
'@repo/jwt':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jwt
|
||||
'@standard-community/standard-json':
|
||||
specifier: 0.3.5
|
||||
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||
|
||||
Reference in New Issue
Block a user