mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[api] add CAI reporting, CircuitChipList path
This commit is contained in:
+288
-21
@@ -16,9 +16,11 @@ import {
|
||||
createCustomAvatarItem,
|
||||
deleteCustomAvatarItem,
|
||||
getCustomAvatarItem,
|
||||
getCustomAvatarItems,
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
listHotCustomAvatarItems,
|
||||
searchCustomAvatarItems,
|
||||
updateCustomAvatarItem,
|
||||
} from '../custom-avatar-items-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
BulkCustomAvatarItemsRequest,
|
||||
CreateCustomAvatarItemRequest,
|
||||
CustomAvatarItemList,
|
||||
CustomAvatarItemReportRequest,
|
||||
CustomAvatarItemResponse,
|
||||
CustomAvatarItemsPage,
|
||||
ErrorResponse,
|
||||
@@ -92,6 +95,42 @@ import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { SavedInvention } from '../inventions-db'
|
||||
|
||||
/**
|
||||
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||||
* EMPTY rather than being truncated.
|
||||
*
|
||||
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
|
||||
* items simply not existing — the client reads the items it got back, not the ids it asked
|
||||
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
|
||||
* difference. Nothing renders this many custom items at once, so a batch this size is the
|
||||
* client doing something other than filling a screen.
|
||||
*/
|
||||
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
|
||||
|
||||
/**
|
||||
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
|
||||
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
|
||||
* too — the client's exact encoding here has not been pinned down, so both are accepted
|
||||
* rather than guessing one and answering nothing when it's the other.
|
||||
*
|
||||
* Each value may itself be a comma-separated list, and blanks are dropped rather than
|
||||
* failing the request: a stray id must not cost the caller the rest of the batch. The order
|
||||
* asked for is preserved, since `getCustomAvatarItems` answers in it.
|
||||
*/
|
||||
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
|
||||
const posted = key === undefined ? [] : body[key]
|
||||
for (const value of Array.isArray(posted) ? posted : [posted]) {
|
||||
if (typeof value === 'string') raw.push(value)
|
||||
}
|
||||
return raw
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate every invention write runs through: the caller must be signed in, the
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or the
|
||||
@@ -450,6 +489,122 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||||
)
|
||||
|
||||
// The store's item search. The client sends the full set of `outfitTypes` it can render
|
||||
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
|
||||
// envelope `fromCreator` uses.
|
||||
//
|
||||
// Several parameters are accepted and not yet acted on; they are listed in the description
|
||||
// rather than dropped silently, because a caller cannot tell the difference between a filter
|
||||
// that was applied and one that was ignored by looking at the results.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Search custom avatar items',
|
||||
description: [
|
||||
'The store’s item search: published items (`Accessibility` 0 is unpublished and is',
|
||||
'left out, from its creator too — `fromCreator` is where they see their own),',
|
||||
'newest first, as a BARE ARRAY.',
|
||||
'`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a',
|
||||
'substring; `%` and `_` in it are literal.',
|
||||
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
|
||||
'rather than no results, since the client sends every type it can render.',
|
||||
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||||
'`skip`/`take` page the results, `take` capped at 200.',
|
||||
'`includeCoachItems=false` leaves out this server’s stock content.',
|
||||
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
|
||||
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
|
||||
'asset variants are stored, and custom avatar items are the only item type there is.',
|
||||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||
'whatever it says.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'searchQuery',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Free text matched against the item’s name or description',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'outfitTypes',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'OutfitType to include; repeat for several. None means all.',
|
||||
schema: { type: 'array', items: { type: 'integer' } },
|
||||
},
|
||||
{
|
||||
name: 'skip',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Rows to skip (default 0)',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Rows to return (default 50, capped at 200)',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'minPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Lowest price to include, inclusive',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'maxPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Highest price to include, inclusive',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'includeCoachItems',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Include the Coach’s stock items (default true)',
|
||||
schema: { type: 'boolean' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||||
}),
|
||||
async (c) => {
|
||||
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
|
||||
// dropped rather than turned into NaN, which would match nothing and quietly empty a
|
||||
// filter the caller believes they set.
|
||||
const outfitTypes = c.req
|
||||
.queries('outfitTypes')
|
||||
?.map((v) => Number.parseInt(v, 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||||
// before comparing; anything that isn't recognisably false leaves the default alone.
|
||||
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
|
||||
|
||||
const int = (name: string): number | undefined => {
|
||||
const raw = c.req.query(name)
|
||||
if (raw === undefined) return undefined
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isInteger(n) ? n : undefined
|
||||
}
|
||||
|
||||
return c.json(
|
||||
await searchCustomAvatarItems(c.env.DB, {
|
||||
searchQuery: c.req.query('searchQuery'),
|
||||
outfitTypes,
|
||||
includeCoachItems,
|
||||
minPrice: int('minPrice'),
|
||||
maxPrice: int('maxPrice'),
|
||||
skip: int('skip'),
|
||||
take: int('take'),
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
|
||||
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
|
||||
// so it is the accessible items, newest first.
|
||||
@@ -467,13 +622,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
|
||||
)
|
||||
|
||||
// A batch lookup of custom avatar items by id. The reference filters a static catalog
|
||||
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
|
||||
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
|
||||
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
|
||||
// ships empty too), so every id misses and the array is empty.
|
||||
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
|
||||
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
|
||||
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
|
||||
// not a 404 for ids it doesn't hold.
|
||||
//
|
||||
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
|
||||
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
|
||||
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
|
||||
// items; the table has existed since migration 0015 and the stub outlived it.
|
||||
//
|
||||
// Auth-gated, and the token is checked before anything else, as the reference does.
|
||||
//
|
||||
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
|
||||
// seen posting far more ids than a screen could draw, and serving those is both a large
|
||||
// query and a large response for a request that is already not what it looks like. Empty is
|
||||
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
|
||||
// client already handles getting back fewer items than it asked about.
|
||||
.post(
|
||||
'/api/customAvatarItems/v1/bulk',
|
||||
describeRoute({
|
||||
@@ -481,25 +646,46 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Custom avatar items in bulk',
|
||||
description:
|
||||
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
||||
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
|
||||
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
|
||||
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
|
||||
'answers this route with the filtered array alone.\n\n' +
|
||||
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
|
||||
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
|
||||
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
|
||||
'reference keeps its catalog in that shape but answers this route with the ' +
|
||||
'filtered array alone.\n\n' +
|
||||
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
||||
'client reads the items it got back rather than the ids it asked for. Nothing ' +
|
||||
'stores custom items here, so every id misses and this is always `[]` — which is ' +
|
||||
'why the posted ids are not parsed.',
|
||||
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
|
||||
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
|
||||
'feeds and the creator shelf apply.\n\n' +
|
||||
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||||
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||||
'exact encoding here has not been pinned down.\n\n' +
|
||||
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||||
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||||
'not an error here.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
||||
responses: {
|
||||
200: json(JsonArray, 'The matching items — always empty here'),
|
||||
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
|
||||
const ids = await bulkCustomAvatarItemIds(c)
|
||||
|
||||
// Over the cap: empty, and the table is not touched. Answering the batch would be a
|
||||
// large query and a large response for a request that is already not what it looks
|
||||
// like — a screen does not draw this many items.
|
||||
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
|
||||
|
||||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||||
// Unpublished items are held back from everyone but their creator — the same rule
|
||||
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||||
// through this route that the feeds hide.
|
||||
return c.json(
|
||||
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -872,7 +1058,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||
// version history yet), so any other version number 404s rather than naming a
|
||||
// blob that isn't there.
|
||||
// blob that isn't there — except `version=0`, which means "whichever is current"
|
||||
// rather than a number to match. Nothing has a version 0, so a caller sending it
|
||||
// doesn't know which version it wants, and matching it literally 404s an invention
|
||||
// that exists.
|
||||
.get(
|
||||
'/api/inventions/v1/version',
|
||||
describeRoute({
|
||||
@@ -883,15 +1072,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||
'is not there.',
|
||||
'is not there.\n\n' +
|
||||
'`version=0` is the exception: it means “whichever is current” rather than a ' +
|
||||
'number to match, and gets the current version. No invention has a version 0 — a ' +
|
||||
'fresh save is version 1 — so a caller sending it does not know which version it ' +
|
||||
'wants, and matching it literally 404s an invention that exists.',
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
intQuery('version', 'Version number; required'),
|
||||
intQuery('version', 'Version number; required. `0` means the current version'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionVersionDto, 'The version'),
|
||||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||||
404: { description: 'No such invention, or not the current version' },
|
||||
404: { description: 'No such invention, or a version number that is not the current one' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -1251,15 +1444,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
//
|
||||
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
|
||||
// answer one page.
|
||||
.get(
|
||||
'/api/inventions/v2/search',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Search / browse inventions',
|
||||
description:
|
||||
'Published inventions matching `value` (matched against name and description), ' +
|
||||
'newest first. An absent `value` browses everything published — that is the ' +
|
||||
'browse screen’s initial request.',
|
||||
'Published inventions matching `value`, newest first. `value` is split into terms ' +
|
||||
'and every term must match, each against the name and the description. An absent ' +
|
||||
'`value` browses everything published — that is the browse screen’s initial ' +
|
||||
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||||
'chips is treated as text and matches nothing.',
|
||||
parameters: [
|
||||
stringQuery('value', 'Search text; absent browses everything'),
|
||||
...pageParams(100),
|
||||
@@ -1301,6 +1499,75 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Report a custom avatar item. Stored in the `report` table the player, event and invention
|
||||
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
|
||||
// migrations/0017_report_custom_avatar_item.sql.
|
||||
//
|
||||
// The item is named by the PATH, not the body, which is what distinguishes this from its
|
||||
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
|
||||
// not know who made the item.
|
||||
.post(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
|
||||
describeRoute({
|
||||
tags: ['Avatar', 'Moderation'],
|
||||
summary: 'Report a custom avatar item',
|
||||
description:
|
||||
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
|
||||
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
|
||||
'an event report and an invention report — the same submission with the same ' +
|
||||
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
|
||||
'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' +
|
||||
'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' +
|
||||
'and IGNORED even when set — the client does not know who made the item, and letting ' +
|
||||
'a client name who a report is against would let it point one at anybody. Nothing ' +
|
||||
'fills `room_id`: an item isn’t tied to one room the way an event is.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), never a body field. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' +
|
||||
'the rows: reporting the same item twice files two reports, and reporting your own ' +
|
||||
'is allowed rather than being a special case.\n\n' +
|
||||
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
|
||||
'`error` being an empty string rather than null, on the rejected branches too so ' +
|
||||
'there is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('id', 'The custom avatar item’s guid')],
|
||||
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const customAvatarItemId = c.req.param('id')
|
||||
|
||||
// The item supplies the reported player. An unknown item is refused rather than filed
|
||||
// against nobody: the row's reported player has to be someone, and a report naming an
|
||||
// item that never existed isn't actionable.
|
||||
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
|
||||
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
|
||||
|
||||
// A body that won't parse is not a reason to lose the report: the path already names
|
||||
// what is being reported and the token names who reported it, so an unreadable body
|
||||
// costs the category and the description, not the row.
|
||||
const body = await c.req
|
||||
.json<{ ReportCategory?: unknown; Details?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const category = Number(body.ReportCategory)
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: item.CreatorAccountId,
|
||||
reportCategory: Number.isInteger(category) ? category : 0,
|
||||
details: typeof body.Details === 'string' ? body.Details : null,
|
||||
customAvatarItemId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||
// same fields, same moderation life — with `invention_id` set. See
|
||||
// migrations/0016_report_invention.sql.
|
||||
|
||||
@@ -227,6 +227,32 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(communityBoard)
|
||||
)
|
||||
// Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into
|
||||
// (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path
|
||||
// segment names the list; nothing here records which chips a player has used or favourited,
|
||||
// so every one of them is empty.
|
||||
//
|
||||
// EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever
|
||||
// palettes its build has, and an unknown one is a palette this server has no opinion about
|
||||
// rather than an error — a 404 shows as a palette that failed to load, where an empty list
|
||||
// shows as one with nothing in it, which is the truth for all of them.
|
||||
.get(
|
||||
'/api/CircuitChipLists/:list',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'One circuit chip list',
|
||||
description:
|
||||
'A palette on the Maker Pen’s circuit board, named by the path (`Favorites`, ' +
|
||||
'`Recent`, …). Always empty: nothing records which chips a player has used or ' +
|
||||
'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' +
|
||||
'for whichever palettes its build has, and a 404 renders as a palette that failed ' +
|
||||
'to load rather than one with nothing in it.',
|
||||
parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')],
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||||
// now, unlike the stubs around them here.
|
||||
.get(
|
||||
|
||||
Reference in New Issue
Block a user