mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
optimize routes
This commit is contained in:
+26
-543
@@ -3,23 +3,18 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import apiConfigV2 from '../static/api-config-v2.json'
|
||||
import gameConfigsV1All from '../static/gameconfigs-v1-all.json'
|
||||
import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
|
||||
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
|
||||
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
|
||||
import { defaultSettings } from './default-settings'
|
||||
import {
|
||||
createImage,
|
||||
getImageByName,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
} from './images-db'
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import { getRoomById } from './rooms-db'
|
||||
import { accountRoutes } from './routes/accounts'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
import { imageRoutes } from './routes/images'
|
||||
import { inventoryRoutes } from './routes/inventory'
|
||||
import { moderationRoutes } from './routes/moderation'
|
||||
import { progressionRoutes } from './routes/progression'
|
||||
import { roomRoutes } from './routes/rooms'
|
||||
import { socialRoutes } from './routes/social'
|
||||
import { storefrontRoutes } from './routes/storefronts'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -28,79 +23,11 @@ import type { App } from './context'
|
||||
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
|
||||
*
|
||||
* Placeholder responses for file-backed endpoints are marked `TODO: hydrate`.
|
||||
*
|
||||
* Routes are grouped into per-domain controllers under `./routes` and mounted
|
||||
* at `/` below. Shared request helpers live in `./http`.
|
||||
*/
|
||||
|
||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
||||
const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token, mirroring the repeated
|
||||
* auth-header check. Returns `null` when the header is missing,
|
||||
* the token is invalid, or the `sub` claim isn't an integer.
|
||||
*/
|
||||
async function authedId(c: Context<App>): Promise<number | null> {
|
||||
const authHeader = c.req.header('Authorization') ?? ''
|
||||
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
|
||||
|
||||
const token = authHeader.slice('Bearer '.length)
|
||||
const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get())
|
||||
if (!accountId) return null
|
||||
|
||||
const id = Number.parseInt(accountId, 10)
|
||||
return Number.isNaN(id) ? null : id
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** Reads the `Ids` form field into a list of integer ids. */
|
||||
async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const ids = body.Ids
|
||||
if (typeof ids !== 'string') return []
|
||||
return ids
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
|
||||
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
|
||||
function queryIds(c: Context<App>): number[] {
|
||||
return (
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** Default reputation for an account — the fallback used with no DB. */
|
||||
function defaultReputation(id: number) {
|
||||
return {
|
||||
AccountId: id,
|
||||
Noteriety: 0,
|
||||
CheerGeneral: 0,
|
||||
CheerHelpful: 0,
|
||||
CheerCreative: 0,
|
||||
CheerGreatHost: 0,
|
||||
CheerSportsman: 0,
|
||||
CheerCredit: 20,
|
||||
SelectedCheer: null,
|
||||
}
|
||||
}
|
||||
|
||||
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`) match either form.
|
||||
const app = new Hono<App>({ strict: false })
|
||||
.use(
|
||||
@@ -116,461 +43,17 @@ const app = new Hono<App>({ strict: false })
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// ---- Config / version ----------------------------------------------------
|
||||
.get('/api/config/v1/amplitude', (c) =>
|
||||
c.json({
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/azurespeech', (c) =>
|
||||
c.json({
|
||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
||||
Region: 'eastus',
|
||||
Enabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/backtrace', (c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 0.025,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex:
|
||||
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
)
|
||||
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
|
||||
// config is static.
|
||||
.get('/api/config/v2', (c) =>
|
||||
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
)
|
||||
.get('/api/versioncheck/v4', (c) =>
|
||||
c.json({
|
||||
VersionStatus: 0,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
|
||||
|
||||
// ---- Social ---------------------------------------------------------------
|
||||
.get('/api/relationships/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
|
||||
// ---- Reputation / progression --------------------------------------------
|
||||
.get('/api/playerReputation/v1/:id', (c) =>
|
||||
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get('/api/players/v1/progression/:id', (c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
})
|
||||
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
|
||||
// Synthesize a default reputation per requested id (the intended behavior;
|
||||
// the DB-less fallback reads a static JSON file instead).
|
||||
.post('/api/playerReputation/v2/bulk', async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
})
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
|
||||
.post('/api/players/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
|
||||
.post('/api/players/v2/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
// Return a default progression per requested id.
|
||||
.get('/api/players/v2/progression/bulk', (c) =>
|
||||
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
)
|
||||
.post('/api/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
// ---- Avatar gifts ---------------------------------------------------------
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
|
||||
// live in the `econ` worker, which the client calls on the econ host — not here.
|
||||
// Only the gift generate/consume actions remain on this worker.
|
||||
.post('/api/avatar/v2/gifts/generate', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftContext =
|
||||
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
|
||||
const message = typeof body.Message === 'string' ? body.Message : ''
|
||||
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
|
||||
|
||||
// No EarnableRewards binding → always fall back to a token gift.
|
||||
const tokenAmounts = [10, 25, 50, 100, 250, 500]
|
||||
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
|
||||
|
||||
return c.json({
|
||||
Id: 0, // TODO: real id once gifts are persisted
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
Currency: currency,
|
||||
Xp: xp,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: 0,
|
||||
GiftContext: giftContext,
|
||||
GiftRarity: 20,
|
||||
Message: message,
|
||||
})
|
||||
})
|
||||
.post('/api/avatar/v2/gifts/consume', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||||
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
|
||||
// No DB → gift can never be found.
|
||||
return c.json({ success: false, error: 'Gift not found' }, 404)
|
||||
})
|
||||
|
||||
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||||
// implementation yet. Each returns a bare JSON boolean; we enable them. Flip
|
||||
// to `false` to disable the corresponding flow.
|
||||
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
|
||||
c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// Voice chat config. The client fetches it to set up voice.
|
||||
// No reference shape, so return an empty object until the client needs fields.
|
||||
.get('/voice/config', (c) => c.json({}))
|
||||
|
||||
// ---- 2023 client loading-path endpoints ------------------------------------
|
||||
// NUX checklist + saved inventions — empty lists with no DB.
|
||||
.get('/api/checklist/v1/current', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
})
|
||||
.get('/api/inventions/v2/mine', (c) => c.json([]))
|
||||
|
||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||
// value back; `isPure` reports the text is clean. The client sanitizes text
|
||||
// during load/display, so a 404 here can stall room entry.
|
||||
.post('/api/sanitize/v1', async (c) => {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return c.json(typeof body.Value === 'string' ? body.Value : '')
|
||||
})
|
||||
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
|
||||
|
||||
// Keepsakes (room mementos). Shapes from the 2025 reference; categories isn't
|
||||
// in any reference, so it's stubbed empty. The client fetches these on room
|
||||
// entry — a 404 stalls the load.
|
||||
.get('/api/keepsakes/globalconfig', (c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
)
|
||||
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
|
||||
.get('/api/keepsakes/categories', (c) => c.json([]))
|
||||
|
||||
// ---- Player reporting -----------------------------------------------------
|
||||
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
|
||||
c.json({
|
||||
ReportCategory: 0,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: '',
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
})
|
||||
)
|
||||
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
|
||||
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
|
||||
|
||||
// ---- Settings -------------------------------------------------------------
|
||||
.get('/api/settings/v2', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: load stored settings; seed defaults on first access.
|
||||
return c.json(defaultSettings(id))
|
||||
})
|
||||
.post('/api/settings/v2/set', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: replace stored settings for `id`.
|
||||
return c.body(null, 200)
|
||||
})
|
||||
|
||||
// ---- Inventory ------------------------------------------------------------
|
||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||
.get('/api/consumables/v2/getUnlocked', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query ConsumableItems
|
||||
})
|
||||
|
||||
// ---- Objectives / events / rewards ---------------------------------------
|
||||
.get('/api/objectives/v1/myprogress', (c) => c.json({})) // TODO: hydrate from JSON/tempmyprogress.json
|
||||
.post('/api/objectives/v1/updateobjective', (c) => c.body(null, 200))
|
||||
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
|
||||
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
|
||||
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
|
||||
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
|
||||
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
||||
|
||||
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||||
.post('/api/gamesight/event', (c) => c.body(null, 200))
|
||||
|
||||
// ---- Subscription ---------------------------------------------------------
|
||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
|
||||
// ---- Storefronts ----------------------------------------------------------
|
||||
.get('/api/storefronts/v4/balance/2', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query TokenBalances
|
||||
})
|
||||
.get('/api/storefronts/v1/p2p/betaEnabled', (c) => c.json(false))
|
||||
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
|
||||
.get('/api/storefronts/v3/giftdropstore/300', (c) => c.json(storefrontGiftDrop300))
|
||||
.get('/api/storefronts/v3/giftdropstore/2', (c) => c.json(storefrontGiftDrop2))
|
||||
.post('/api/storefronts/v2/buyItem', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// No StorefrontItems binding → item can never be found.
|
||||
return c.json({ error: 'Item not found' }, 404)
|
||||
})
|
||||
|
||||
// ---- Accounts -------------------------------------------------------------
|
||||
.get('/api/accounts/v1/getBio', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query PlayerBios
|
||||
})
|
||||
.post('/api/accounts/v1/forplatformids', async (c) => {
|
||||
await parseFormIds(c) // reads `Ids` then looks up CachedLogins
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
// ---- Room keys / quick play ----------------------------------------------
|
||||
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
|
||||
.get('/api/roomkeys/v1/room', (c) => c.json([]))
|
||||
.get('/api/quickPlay/v1/getandclear', (c) =>
|
||||
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
|
||||
)
|
||||
|
||||
// ---- Images ---------------------------------------------------------------
|
||||
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
|
||||
.post('/api/images/v4/uploadsaved', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// The client posts the file as `image`; accept `file` too for safety.
|
||||
const candidate = body.image ?? body.file
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
|
||||
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||
let meta: Record<string, unknown> = {}
|
||||
if (typeof body.imgMeta === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body.imgMeta)
|
||||
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
// Malformed imgMeta — treat as an untyped upload (still stored).
|
||||
}
|
||||
}
|
||||
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
|
||||
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
|
||||
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||
const dot = file.name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
||||
const extension = valid.includes(ext) ? ext : '.jpg'
|
||||
|
||||
// Store the upload in the shared image bucket under a random key. The `img`
|
||||
// worker serves it back by that key, which is the returned ImageName.
|
||||
const name = crypto.randomUUID().replace(/-/g, '') + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
|
||||
// A profile thumbnail becomes the account's avatar — persist it on the
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
if (savedImageType === SavedImageType.ProfileThumbnail) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, name)
|
||||
.run()
|
||||
}
|
||||
|
||||
// Record the image metadata (the `image` table the img worker owns), pulling
|
||||
// the fields the client provided in imgMeta.
|
||||
await createImage(c.env.DB, {
|
||||
imageName: name,
|
||||
playerId: id,
|
||||
type: savedImageType,
|
||||
accessibility: num(meta.accessibility),
|
||||
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
|
||||
description: typeof meta.description === 'string' ? meta.description : null,
|
||||
taggedPlayerIds: Array.isArray(meta.playerIds)
|
||||
? meta.playerIds.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
|
||||
})
|
||||
|
||||
return c.json({ ImageName: name })
|
||||
})
|
||||
|
||||
// A room's photo feed — the public images taken in that room. `sort` orders the
|
||||
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
|
||||
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
})
|
||||
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
|
||||
})
|
||||
|
||||
// A player's photos with a sort option. `sort` orders the list (1 = most
|
||||
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
|
||||
})
|
||||
|
||||
// A player's photo feed — the public images they took plus ones they're tagged
|
||||
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
||||
})
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
.get('/api/images/v6', async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
})
|
||||
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ---- Rooms ----------------------------------------------------------------
|
||||
// Room search filters. The client deserializes this into an object (not an
|
||||
// array) — shape from the 2025 reference.
|
||||
.get('/api/rooms/v1/filters', (c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
|
||||
})
|
||||
)
|
||||
|
||||
// Verify the caller holds at least `role` in a room. Params come from the form
|
||||
// body (falling back to the query string). Returns a bare `true`/`false`: the
|
||||
// room creator always passes; otherwise the caller needs a Roles entry with
|
||||
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
|
||||
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
|
||||
.post('/api/rooms/v1/verifyRole', async (c) => {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const param = (name: string): string => {
|
||||
const form = body[name]
|
||||
if (typeof form === 'string' && form !== '') return form
|
||||
return c.req.query(name) ?? ''
|
||||
}
|
||||
const roomId = Number.parseInt(param('roomId'), 10)
|
||||
const role = Number.parseInt(param('role'), 10)
|
||||
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.json(false)
|
||||
|
||||
// The creator always passes.
|
||||
if (room.CreatorAccountId === accountId) return c.json(true)
|
||||
|
||||
// Otherwise the caller needs a room role at least as high as requested.
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
|
||||
const hasRole = roles.some(
|
||||
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
|
||||
)
|
||||
return c.json(hasRole)
|
||||
})
|
||||
// ---- Controllers ----------------------------------------------------------
|
||||
.route('/', configRoutes)
|
||||
.route('/', socialRoutes)
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', moderationRoutes)
|
||||
.route('/', inventoryRoutes)
|
||||
.route('/', storefrontRoutes)
|
||||
.route('/', accountRoutes)
|
||||
.route('/', roomRoutes)
|
||||
.route('/', imageRoutes)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token, mirroring the repeated
|
||||
* auth-header check. Returns `null` when the header is missing,
|
||||
* the token is invalid, or the `sub` claim isn't an integer.
|
||||
*/
|
||||
export async function authedId(c: Context<App>): Promise<number | null> {
|
||||
const authHeader = c.req.header('Authorization') ?? ''
|
||||
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
|
||||
|
||||
const token = authHeader.slice('Bearer '.length)
|
||||
const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get())
|
||||
if (!accountId) return null
|
||||
|
||||
const id = Number.parseInt(accountId, 10)
|
||||
return Number.isNaN(id) ? null : id
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
export function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** Reads the `Ids` form field into a list of integer ids. */
|
||||
export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const ids = body.Ids
|
||||
if (typeof ids !== 'string') return []
|
||||
return ids
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
|
||||
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
|
||||
export function queryIds(c: Context<App>): number[] {
|
||||
return (
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authedId, parseFormIds, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Accounts --------------------------------------------------------------
|
||||
export const accountRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/accounts/v1/getBio', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query PlayerBios
|
||||
})
|
||||
.post('/api/accounts/v1/forplatformids', async (c) => {
|
||||
await parseFormIds(c) // reads `Ids` then looks up CachedLogins
|
||||
return c.json([])
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Avatar gifts ----------------------------------------------------------
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
|
||||
// live in the `econ` worker, which the client calls on the econ host — not here.
|
||||
// Only the gift generate/consume actions remain on this worker.
|
||||
export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
.post('/api/avatar/v2/gifts/generate', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftContext =
|
||||
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
|
||||
const message = typeof body.Message === 'string' ? body.Message : ''
|
||||
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
|
||||
|
||||
// No EarnableRewards binding → always fall back to a token gift.
|
||||
const tokenAmounts = [10, 25, 50, 100, 250, 500]
|
||||
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
|
||||
|
||||
return c.json({
|
||||
Id: 0, // TODO: real id once gifts are persisted
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
Currency: currency,
|
||||
Xp: xp,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: 0,
|
||||
GiftContext: giftContext,
|
||||
GiftRarity: 20,
|
||||
Message: message,
|
||||
})
|
||||
})
|
||||
.post('/api/avatar/v2/gifts/consume', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||||
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
|
||||
// No DB → gift can never be found.
|
||||
return c.json({ success: false, error: 'Gift not found' }, 404)
|
||||
})
|
||||
|
||||
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||||
// implementation yet. Each returns a bare JSON boolean; we enable them. Flip
|
||||
// to `false` to disable the corresponding flow.
|
||||
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
|
||||
c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// Saved inventions — empty list with no DB.
|
||||
.get('/api/inventions/v2/mine', (c) => c.json([]))
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Config / version ------------------------------------------------------
|
||||
export const configRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/config/v1/amplitude', (c) =>
|
||||
c.json({
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/azurespeech', (c) =>
|
||||
c.json({
|
||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
||||
Region: 'eastus',
|
||||
Enabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/backtrace', (c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 0.025,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex:
|
||||
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
)
|
||||
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
|
||||
// config is static.
|
||||
.get('/api/config/v2', (c) =>
|
||||
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
)
|
||||
.get('/api/versioncheck/v4', (c) =>
|
||||
c.json({
|
||||
VersionStatus: 0,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
|
||||
|
||||
// Voice chat config. The client fetches it to set up voice.
|
||||
// No reference shape, so return an empty object until the client needs fields.
|
||||
.get('/voice/config', (c) => c.json({}))
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- 2023 client loading-path endpoints ------------------------------------
|
||||
// NUX checklist, text sanitization, keepsakes, objectives/events/rewards, and
|
||||
// the misc analytics/subscription sinks the client hits during load.
|
||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
// NUX checklist — empty list with no DB.
|
||||
.get('/api/checklist/v1/current', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||
// value back; `isPure` reports the text is clean. The client sanitizes text
|
||||
// during load/display, so a 404 here can stall room entry.
|
||||
.post('/api/sanitize/v1', async (c) => {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return c.json(typeof body.Value === 'string' ? body.Value : '')
|
||||
})
|
||||
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
|
||||
|
||||
// Keepsakes (room mementos). Shapes from the 2025 reference; categories isn't
|
||||
// in any reference, so it's stubbed empty. The client fetches these on room
|
||||
// entry — a 404 stalls the load.
|
||||
.get('/api/keepsakes/globalconfig', (c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
)
|
||||
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
|
||||
.get('/api/keepsakes/categories', (c) => c.json([]))
|
||||
|
||||
// ---- Objectives / events / rewards ---------------------------------------
|
||||
.get('/api/objectives/v1/myprogress', (c) => c.json({})) // TODO: hydrate from JSON/tempmyprogress.json
|
||||
.post('/api/objectives/v1/updateobjective', (c) => c.body(null, 200))
|
||||
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
|
||||
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
|
||||
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
|
||||
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
|
||||
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
||||
|
||||
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||||
.post('/api/gamesight/event', (c) => c.body(null, 200))
|
||||
|
||||
// ---- Subscription ---------------------------------------------------------
|
||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
createImage,
|
||||
getImageByName,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
} from '../images-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
||||
const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
export const imageRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
|
||||
.post('/api/images/v4/uploadsaved', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// The client posts the file as `image`; accept `file` too for safety.
|
||||
const candidate = body.image ?? body.file
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
|
||||
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||
let meta: Record<string, unknown> = {}
|
||||
if (typeof body.imgMeta === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body.imgMeta)
|
||||
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
// Malformed imgMeta — treat as an untyped upload (still stored).
|
||||
}
|
||||
}
|
||||
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
|
||||
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
|
||||
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||
const dot = file.name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
||||
const extension = valid.includes(ext) ? ext : '.jpg'
|
||||
|
||||
// Store the upload in the shared image bucket under a random key. The `img`
|
||||
// worker serves it back by that key, which is the returned ImageName.
|
||||
const name = crypto.randomUUID().replace(/-/g, '') + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
|
||||
// A profile thumbnail becomes the account's avatar — persist it on the
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
if (savedImageType === SavedImageType.ProfileThumbnail) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, name)
|
||||
.run()
|
||||
}
|
||||
|
||||
// Record the image metadata (the `image` table the img worker owns), pulling
|
||||
// the fields the client provided in imgMeta.
|
||||
await createImage(c.env.DB, {
|
||||
imageName: name,
|
||||
playerId: id,
|
||||
type: savedImageType,
|
||||
accessibility: num(meta.accessibility),
|
||||
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
|
||||
description: typeof meta.description === 'string' ? meta.description : null,
|
||||
taggedPlayerIds: Array.isArray(meta.playerIds)
|
||||
? meta.playerIds.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
|
||||
})
|
||||
|
||||
return c.json({ ImageName: name })
|
||||
})
|
||||
|
||||
// A room's photo feed — the public images taken in that room. `sort` orders the
|
||||
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
|
||||
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
})
|
||||
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
|
||||
})
|
||||
|
||||
// A player's photos with a sort option. `sort` orders the list (1 = most
|
||||
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
|
||||
})
|
||||
|
||||
// A player's photo feed — the public images they took plus ones they're tagged
|
||||
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
||||
})
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
.get('/api/images/v6', async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
})
|
||||
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { defaultSettings } from '../default-settings'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Settings / inventory --------------------------------------------------
|
||||
export const inventoryRoutes = new Hono<App>({ strict: false })
|
||||
// ---- Settings -------------------------------------------------------------
|
||||
.get('/api/settings/v2', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: load stored settings; seed defaults on first access.
|
||||
return c.json(defaultSettings(id))
|
||||
})
|
||||
.post('/api/settings/v2/set', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: replace stored settings for `id`.
|
||||
return c.body(null, 200)
|
||||
})
|
||||
|
||||
// ---- Inventory ------------------------------------------------------------
|
||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||
.get('/api/consumables/v2/getUnlocked', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query ConsumableItems
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
|
||||
c.json({
|
||||
ReportCategory: 0,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: '',
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
})
|
||||
)
|
||||
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
|
||||
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { parseFormIds, queryIds } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/** Default reputation for an account — the fallback used with no DB. */
|
||||
function defaultReputation(id: number) {
|
||||
return {
|
||||
AccountId: id,
|
||||
Noteriety: 0,
|
||||
CheerGeneral: 0,
|
||||
CheerHelpful: 0,
|
||||
CheerCreative: 0,
|
||||
CheerGreatHost: 0,
|
||||
CheerSportsman: 0,
|
||||
CheerCredit: 20,
|
||||
SelectedCheer: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reputation / progression ----------------------------------------------
|
||||
export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/playerReputation/v1/:id', (c) =>
|
||||
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get('/api/players/v1/progression/:id', (c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
})
|
||||
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
|
||||
// Synthesize a default reputation per requested id (the intended behavior;
|
||||
// the DB-less fallback reads a static JSON file instead).
|
||||
.post('/api/playerReputation/v2/bulk', async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
})
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
|
||||
.post('/api/players/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
|
||||
.post('/api/players/v2/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
// Return a default progression per requested id.
|
||||
.get('/api/players/v2/progression/bulk', (c) =>
|
||||
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
)
|
||||
.post('/api/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authedId } from '../http'
|
||||
import { getRoomById } from '../rooms-db'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Room keys / quick play / rooms ----------------------------------------
|
||||
export const roomRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
|
||||
.get('/api/roomkeys/v1/room', (c) => c.json([]))
|
||||
.get('/api/quickPlay/v1/getandclear', (c) =>
|
||||
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
|
||||
)
|
||||
|
||||
// Room search filters. The client deserializes this into an object (not an
|
||||
// array) — shape from the 2025 reference.
|
||||
.get('/api/rooms/v1/filters', (c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
|
||||
})
|
||||
)
|
||||
|
||||
// Verify the caller holds at least `role` in a room. Params come from the form
|
||||
// body (falling back to the query string). Returns a bare `true`/`false`: the
|
||||
// room creator always passes; otherwise the caller needs a Roles entry with
|
||||
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
|
||||
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
|
||||
.post('/api/rooms/v1/verifyRole', async (c) => {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const param = (name: string): string => {
|
||||
const form = body[name]
|
||||
if (typeof form === 'string' && form !== '') return form
|
||||
return c.req.query(name) ?? ''
|
||||
}
|
||||
const roomId = Number.parseInt(param('roomId'), 10)
|
||||
const role = Number.parseInt(param('role'), 10)
|
||||
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.json(false)
|
||||
|
||||
// The creator always passes.
|
||||
if (room.CreatorAccountId === accountId) return c.json(true)
|
||||
|
||||
// Otherwise the caller needs a room role at least as high as requested.
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
|
||||
const hasRole = roles.some(
|
||||
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
|
||||
)
|
||||
return c.json(hasRole)
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Social ----------------------------------------------------------------
|
||||
export const socialRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/relationships/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import storefrontGiftDrop2 from '../../static/storefronts-v3-giftdropstore-2.json'
|
||||
import storefrontGiftDrop3 from '../../static/storefronts-v3-giftdropstore-3.json'
|
||||
import storefrontGiftDrop300 from '../../static/storefronts-v3-giftdropstore-300.json'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Storefronts -----------------------------------------------------------
|
||||
export const storefrontRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/storefronts/v4/balance/2', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query TokenBalances
|
||||
})
|
||||
.get('/api/storefronts/v1/p2p/betaEnabled', (c) => c.json(false))
|
||||
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
|
||||
.get('/api/storefronts/v3/giftdropstore/300', (c) => c.json(storefrontGiftDrop300))
|
||||
.get('/api/storefronts/v3/giftdropstore/2', (c) => c.json(storefrontGiftDrop2))
|
||||
.post('/api/storefronts/v2/buyItem', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// No StorefrontItems binding → item can never be found.
|
||||
return c.json({ error: 'Item not found' }, 404)
|
||||
})
|
||||
Reference in New Issue
Block a user