mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -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 { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import apiConfigV2 from '../static/api-config-v2.json'
|
import { accountRoutes } from './routes/accounts'
|
||||||
import gameConfigsV1All from '../static/gameconfigs-v1-all.json'
|
import { avatarRoutes } from './routes/avatar'
|
||||||
import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
|
import { configRoutes } from './routes/config'
|
||||||
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
|
import { gameplayRoutes } from './routes/gameplay'
|
||||||
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
|
import { imageRoutes } from './routes/images'
|
||||||
import { defaultSettings } from './default-settings'
|
import { inventoryRoutes } from './routes/inventory'
|
||||||
import {
|
import { moderationRoutes } from './routes/moderation'
|
||||||
createImage,
|
import { progressionRoutes } from './routes/progression'
|
||||||
getImageByName,
|
import { roomRoutes } from './routes/rooms'
|
||||||
getImagesByPlayer,
|
import { socialRoutes } from './routes/social'
|
||||||
getImagesByRoom,
|
import { storefrontRoutes } from './routes/storefronts'
|
||||||
getPlayerFeed,
|
|
||||||
} from './images-db'
|
|
||||||
import { validateAndGetAccountId } from './jwt'
|
|
||||||
import { getRoomById } from './rooms-db'
|
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
|
||||||
import type { App } from './context'
|
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.
|
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
|
||||||
*
|
*
|
||||||
* Placeholder responses for file-backed endpoints are marked `TODO: hydrate`.
|
* 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.
|
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`) match either form.
|
||||||
const app = new Hono<App>({ strict: false })
|
const app = new Hono<App>({ strict: false })
|
||||||
.use(
|
.use(
|
||||||
@@ -116,461 +43,17 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
// ---- Config / version ----------------------------------------------------
|
// ---- Controllers ----------------------------------------------------------
|
||||||
.get('/api/config/v1/amplitude', (c) =>
|
.route('/', configRoutes)
|
||||||
c.json({
|
.route('/', socialRoutes)
|
||||||
AmplitudeKey: 'a',
|
.route('/', progressionRoutes)
|
||||||
StatSigKey: 'a',
|
.route('/', avatarRoutes)
|
||||||
RudderStackKey: 'a',
|
.route('/', gameplayRoutes)
|
||||||
UseRudderStack: false,
|
.route('/', moderationRoutes)
|
||||||
})
|
.route('/', inventoryRoutes)
|
||||||
)
|
.route('/', storefrontRoutes)
|
||||||
.get('/api/config/v1/azurespeech', (c) =>
|
.route('/', accountRoutes)
|
||||||
c.json({
|
.route('/', roomRoutes)
|
||||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
.route('/', imageRoutes)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app
|
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