mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs
This commit is contained in:
@@ -33,6 +33,10 @@ export interface Account {
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
/** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */
|
||||
bannerImage: string
|
||||
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
|
||||
displayEmoji: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
personalPronouns: number
|
||||
@@ -164,6 +168,8 @@ export function defaultAccount(id: number, overrides: Partial<Account> = {}): Ac
|
||||
username: `Player${id}`,
|
||||
displayName: `Player${id}`,
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
isJunior: false,
|
||||
platforms: 0,
|
||||
personalPronouns: 0,
|
||||
|
||||
@@ -163,6 +163,9 @@ export async function setImageCheer(
|
||||
await syncImageCheerCount(db, savedImageId)
|
||||
}
|
||||
|
||||
/** How many image ids one cheer lookup may bind: D1's 100-parameter cap, less the player id. */
|
||||
const CHEER_ID_LIMIT = 99
|
||||
|
||||
/**
|
||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||
@@ -172,16 +175,23 @@ export async function getCheeredImageIds(
|
||||
playerId: number,
|
||||
ids: number[]
|
||||
): Promise<Set<number>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
const cheered = new Set<number>()
|
||||
// D1 caps a query at 100 bound parameters and the player id takes one of them, so a
|
||||
// photo grid asking about more images than that is split across queries rather than
|
||||
// failing the whole read. The client really does send a page of ~100 at a time.
|
||||
for (let i = 0; i < ids.length; i += CHEER_ID_LIMIT) {
|
||||
const page = ids.slice(i, i + CHEER_ID_LIMIT)
|
||||
const inList = page.map((_, n) => `?${n + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||
)
|
||||
.bind(playerId, ...ids)
|
||||
.all<{ id: number }>()
|
||||
return new Set(results.map((r) => r.id))
|
||||
)
|
||||
.bind(playerId, ...page)
|
||||
.all<{ id: number }>()
|
||||
for (const row of results) cheered.add(row.id)
|
||||
}
|
||||
return cheered
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
@@ -214,6 +224,40 @@ export async function getSavedImagesByNames(
|
||||
)
|
||||
}
|
||||
|
||||
/** How many image ids one bulk lookup may bind: D1 caps a query at 100 parameters. */
|
||||
const IMAGE_ID_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Look up image records by id — the bulk lookup behind `GET /api/images/v5/bulk`.
|
||||
* Returned in REQUEST order, so the caller can line the answers up with what it asked
|
||||
* for; an id with no record, or one that isn't public, is simply absent rather than a
|
||||
* hole in the list.
|
||||
*
|
||||
* Public-only, like every other image read here ({@link getImagesByPlayer},
|
||||
* {@link getImagesByRoom}). Image ids are sequential, so serving whatever an id names
|
||||
* would make a private photo readable by anyone who counts.
|
||||
*/
|
||||
export async function getImagesByIds(db: D1Database, ids: number[]): Promise<SavedImage[]> {
|
||||
if (ids.length === 0) return []
|
||||
|
||||
const found = new Map<number, SavedImage>()
|
||||
// D1 caps a query at 100 bound parameters, and the client asks about a whole photo
|
||||
// grid at once, so a large request is split rather than failing outright.
|
||||
for (let i = 0; i < ids.length; i += IMAGE_ID_LIMIT) {
|
||||
const page = ids.slice(i, i + IMAGE_ID_LIMIT)
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM image WHERE id IN (${placeholders(page.length)})`)
|
||||
.bind(...page)
|
||||
.all<ImageRow>()
|
||||
for (const row of results) {
|
||||
const image = JSON.parse(row.data) as SavedImage
|
||||
if (image.Accessibility === 1) found.set(image.Id, image)
|
||||
}
|
||||
}
|
||||
|
||||
return ids.map((id) => found.get(id)).filter((image): image is SavedImage => image !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal SavedImage for an image name with no metadata row — enough for the client
|
||||
* to render the picture. Uploads normally write a row first, so this only covers a
|
||||
@@ -311,6 +355,55 @@ export async function getImagesByPlayer(
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* An image's metadata as `GET /api/images/v6` serves it — the by-name lookup's shape.
|
||||
*
|
||||
* A THIRD projection of the same row, and deliberately not either of the other two: it
|
||||
* renames like `ImagesPlayer` (`Id` → `SavedImageId`, `Type` → `SavedImageType`, no
|
||||
* `TaggedPlayerIds`) but adds `ClubId`, and its numbers and strings are never null —
|
||||
* `RoomId`, `PlayerEventId` and `ClubId` come out as 0 and `Description` as `""` where the
|
||||
* row holds null. The reference's DTO declares them non-nullable, so a null is a decode
|
||||
* failure rather than "none".
|
||||
*
|
||||
* `ClubId` is always 0: nothing here associates an image with a club.
|
||||
*/
|
||||
export interface ImageMetadata {
|
||||
SavedImageId: number
|
||||
ImageName: string
|
||||
PlayerId: number
|
||||
RoomId: number
|
||||
PlayerEventId: number
|
||||
ClubId: number
|
||||
Description: string
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
SavedImageType: number
|
||||
CreatedAt: string
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
}
|
||||
|
||||
/** Project a stored image into the {@link ImageMetadata} shape `/api/images/v6` answers. */
|
||||
export function toImageMetadata(img: SavedImage): ImageMetadata {
|
||||
return {
|
||||
SavedImageId: img.Id,
|
||||
ImageName: img.ImageName,
|
||||
PlayerId: img.PlayerId,
|
||||
// Null means "not taken in a room" / "no event"; the client's DTO has no null to put
|
||||
// there, and 0 is the id it treats as none.
|
||||
RoomId: img.RoomId ?? 0,
|
||||
PlayerEventId: img.PlayerEventId ?? 0,
|
||||
ClubId: 0,
|
||||
Description: img.Description ?? '',
|
||||
Accessibility: img.Accessibility,
|
||||
AccessibilityLocked: img.AccessibilityLocked,
|
||||
SavedImageType: img.Type,
|
||||
CreatedAt: img.CreatedAt,
|
||||
CheerCount: img.CheerCount,
|
||||
CommentCount: img.CommentCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-facing projection of a saved image for the player photo lists (the
|
||||
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './room-instance-db'
|
||||
export * from './presence-db'
|
||||
export * from './gifts-db'
|
||||
export * from './inventory-invention-db'
|
||||
export * from './outfits-db'
|
||||
export * from './progression-db'
|
||||
export * from './relationships-db'
|
||||
export * from './validation'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
|
||||
* saves from the avatar screen.
|
||||
*
|
||||
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
|
||||
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
|
||||
* FaceFeatures, …) are themselves JSON-in-a-string produced by the client's own
|
||||
* serializer. Round-tripping it verbatim is both the simplest and the safest thing —
|
||||
* re-encoding risks changing a payload the client has to parse back.
|
||||
*
|
||||
* The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and
|
||||
* serves the slot list (`GET /api/avatar/v3/saved`, `POST /api/avatar/v3/saved/set`). The
|
||||
* `api` worker reads and writes SLOT 0 through `/outfits/me` — the newer client treats
|
||||
* slot 0 as the outfit currently worn. Both import these helpers so the table name and
|
||||
* row shape live in one place.
|
||||
*
|
||||
* Note the two write paths store DIFFERENT payload shapes into the same column: econ's
|
||||
* saved-outfit slots hold the old flat PascalCase outfit, while `/outfits/me` holds the
|
||||
* newer `{ DataVersion, LegacyData, CustomizationSettings, … }` envelope. Each endpoint
|
||||
* serves back what it stored, so don't add a projection that assumes either one.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
|
||||
export const OUTFIT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS outfit (
|
||||
account_id INTEGER NOT NULL,
|
||||
set_id INTEGER NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, set_id)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
|
||||
* `set_id` column) — saving to a slot the player already used overwrites it, which is
|
||||
* exactly what the avatar screen's "save over this outfit" does. The rest of the
|
||||
* payload is stored and served back untouched.
|
||||
*/
|
||||
export interface Outfit extends Record<string, unknown> {
|
||||
Slot: number
|
||||
}
|
||||
|
||||
/** The slot the newer client wears — what `/outfits/me` reads and writes. */
|
||||
export const CURRENT_OUTFIT_SLOT = 0
|
||||
|
||||
/** Every outfit a player has saved, ordered by slot. */
|
||||
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 ORDER BY set_id')
|
||||
.bind(accountId)
|
||||
.all<{ avatar: string }>()
|
||||
return results.map((r) => JSON.parse(r.avatar) as Outfit)
|
||||
}
|
||||
|
||||
/** One slot's outfit, or null when the player has never saved into it. */
|
||||
export async function getOutfit(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
slot: number
|
||||
): Promise<Outfit | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 AND set_id = ?2')
|
||||
.bind(accountId, slot)
|
||||
.first<{ avatar: string }>()
|
||||
return row ? (JSON.parse(row.avatar) as Outfit) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an outfit into one of the player's slots, replacing whatever was there. The
|
||||
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
|
||||
* accumulating duplicate rows for it.
|
||||
*/
|
||||
export async function setOutfit(db: D1Database, accountId: number, outfit: Outfit): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO outfit (account_id, set_id, avatar) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (account_id, set_id) DO UPDATE SET avatar = ?3`
|
||||
)
|
||||
.bind(accountId, outfit.Slot, JSON.stringify(outfit))
|
||||
.run()
|
||||
}
|
||||
@@ -32,6 +32,29 @@ export const PRESENCE_TTL_SECONDS = 900
|
||||
*/
|
||||
export const GAME_VERSION = '20230414'
|
||||
|
||||
/**
|
||||
* Client builds `/api/versioncheck/v4` answers "current" for. `GAME_VERSION` is the one
|
||||
* the rest of the stack targets and reports for itself; the others are later clients
|
||||
* that talk close enough to the same protocol to get past the update prompt.
|
||||
*
|
||||
* DEBUGGING ONLY beyond `GAME_VERSION`: this is not a supported-version list. Nothing
|
||||
* else in the stack targets those builds, so a client waved through here can still hit
|
||||
* protocol differences the version check would otherwise have caught. Trim it back to
|
||||
* `GAME_VERSION` alone before anyone but us is playing.
|
||||
*/
|
||||
export const SUPPORTED_GAME_VERSIONS: string[] = [
|
||||
GAME_VERSION,
|
||||
'20230616',
|
||||
'20231207',
|
||||
'20250424.01',
|
||||
'20250718.01',
|
||||
]
|
||||
|
||||
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
|
||||
export function isSupportedGameVersion(version: string | null | undefined): boolean {
|
||||
return version != null && SUPPORTED_GAME_VERSIONS.includes(version)
|
||||
}
|
||||
|
||||
/** Schema DDL (mirror of migrations/0006_presence.sql). */
|
||||
export const PRESENCE_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS presence (
|
||||
|
||||
@@ -199,6 +199,36 @@ export async function areFriends(
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of a player's friends are online right now — the friend graph joined to live
|
||||
* `presence`, which is the count the client's friends panel shows. `Friend` rows only,
|
||||
* from either side of the pair, and only unexpired presence (rows outlive the player by
|
||||
* up to the TTL, exactly as every other presence read treats them). Lobby (null-instance)
|
||||
* presence counts: those friends are signed in, just not in a room.
|
||||
*
|
||||
* A friend's `statusVisibility` is deliberately NOT consulted — nothing else in the stack
|
||||
* filters presence on it (the batch `/player` lookup reports it and lets the client
|
||||
* decide), so hiding people here would disagree with the list the panel then renders.
|
||||
*/
|
||||
export async function countOnlineFriends(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now = Math.floor(Date.now() / 1000)
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM relationship r
|
||||
JOIN presence p
|
||||
ON p.account_id = CASE WHEN r.requester_id = ?1 THEN r.target_id ELSE r.requester_id END
|
||||
WHERE r.relationship_type = ?2
|
||||
AND (r.requester_id = ?1 OR r.target_id = ?1)
|
||||
AND p.expires_at > ?3`
|
||||
)
|
||||
.bind(playerId, RelationshipType.Friend, now)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||
|
||||
|
||||
@@ -312,6 +312,9 @@ export async function cloneRoom(
|
||||
clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId }))
|
||||
}
|
||||
cloned.SubRooms = clonedSubRooms
|
||||
// Inherited from the (parsed) source in practice; defaulted here too so a clone is
|
||||
// never the one room shape missing them.
|
||||
attachRoomDtoDefaults(cloned)
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -869,6 +872,24 @@ interface RoomRow {
|
||||
*/
|
||||
const ROOM_COLUMNS = 'data, visits'
|
||||
|
||||
/**
|
||||
* Two keys on the client's room DTO that nothing here stores, defaulted on every read so
|
||||
* the key is PRESENT rather than absent — the seed blobs and every room written since
|
||||
* predate them, so they can't come from the data:
|
||||
*
|
||||
* - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so
|
||||
* it is 0 for every room.
|
||||
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
|
||||
* null, which is also what the reference serves for a room that has none.
|
||||
*
|
||||
* Defaulted rather than assigned, so a stored value wins if either is ever really written
|
||||
* (a blob keeps whatever `serializeRoom` last put in it).
|
||||
*/
|
||||
function attachRoomDtoDefaults(room: Room): void {
|
||||
room.BoostCount ??= 0
|
||||
room.CurrentSnapshotId ??= null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a room row: the stored blob with the counters the columns own folded back in.
|
||||
* `visits` is a real column, so a room read straight from the DB carries the live count.
|
||||
@@ -876,6 +897,7 @@ const ROOM_COLUMNS = 'data, visits'
|
||||
const parseRow = (row: RoomRow): Room => {
|
||||
const room = JSON.parse(row.data) as Room
|
||||
room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 }
|
||||
attachRoomDtoDefaults(room)
|
||||
return room
|
||||
}
|
||||
|
||||
@@ -1476,7 +1498,14 @@ export async function getRoomByName(db: D1Database, name: string): Promise<Room
|
||||
)
|
||||
}
|
||||
|
||||
/** Look up multiple rooms by RoomId. */
|
||||
/**
|
||||
* Look up multiple rooms by RoomId.
|
||||
*
|
||||
* Every id is bound into one query, so the CALLER must keep the list within D1's cap of 100
|
||||
* bound parameters — `/rooms/bulk` rejects a longer request with a 400 rather than have this
|
||||
* split it, since a client asking about more than a hundred rooms at once is asking the
|
||||
* wrong question.
|
||||
*/
|
||||
export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||||
@@ -1513,6 +1542,36 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms an account CONTRIBUTES to: the ones whose `Roles` name them (Host, Moderator or
|
||||
* CoOwner), minus the ones they created themselves.
|
||||
*
|
||||
* The creator is excluded deliberately. A room's `Roles` carries its creator too, so
|
||||
* without that filter this list would repeat everything `getRoomsByCreator` already
|
||||
* serves — and the client shows "rooms you own" and "rooms you contribute to" as two
|
||||
* separate lists. Every role tier counts here, unlike {@link canManageRoom}'s
|
||||
* owner-or-co-owner gate: this is "somebody gave you a job in their room", not "you may
|
||||
* administer it".
|
||||
*
|
||||
* Roles live inside the room blob rather than in a table of their own, so the match is a
|
||||
* `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no
|
||||
* rows there rather than erroring, so it drops out of the list.
|
||||
*/
|
||||
export async function getContributedRooms(db: D1Database, accountId: number): Promise<Room[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE creator_account_id IS NOT ?1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(room.data, '$.Roles') AS role
|
||||
WHERE json_extract(role.value, '$.AccountId') = ?1
|
||||
)`
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<RoomRow>()
|
||||
return hydrateRooms(db, parseAll(results))
|
||||
}
|
||||
|
||||
/**
|
||||
* An account's public, non-dorm rooms — the publicly viewable "rooms owned by
|
||||
* <player>" list (excludes private rooms, dorms, and list-excluded rooms).
|
||||
@@ -1740,6 +1799,67 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions for the box the player is typing in
|
||||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||||
*
|
||||
* Everything suggested is something the follow-up `/rooms/search` will actually find,
|
||||
* which is the whole point of the endpoint: a suggestion that returns nothing is worse
|
||||
* than no suggestion. So the candidates are drawn from the two things that search matches
|
||||
* — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public,
|
||||
* non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the
|
||||
* suggestion verbatim searches by tag rather than for a room called "horror".
|
||||
*
|
||||
* A query starting with `#` is asking for tags, so only tags are suggested. Otherwise
|
||||
* names come first (the likelier intent), then tags, and within each, matches that START
|
||||
* with the query come before ones that merely contain it. Ties break alphabetically, so
|
||||
* the same query always suggests the same things in the same order.
|
||||
*
|
||||
* Matching is case-insensitive and suggestions are de-duplicated case-insensitively, but
|
||||
* each is returned in its stored casing — search doesn't care, and the player reads these.
|
||||
* The dataset is small, so this filters in memory like {@link searchRooms}.
|
||||
*/
|
||||
export async function autocompleteRoomSearch(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
take: number
|
||||
): Promise<string[]> {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '' || take <= 0) return []
|
||||
|
||||
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
|
||||
const rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)
|
||||
|
||||
const tagQuery = q.startsWith('#')
|
||||
const term = tagQuery ? q.slice(1) : q
|
||||
if (term === '') return []
|
||||
|
||||
// Lowercased suggestion → [rank, the casing to serve it in]. Lower rank sorts first.
|
||||
const found = new Map<string, [number, string]>()
|
||||
const offer = (value: string, rank: number) => {
|
||||
const key = value.toLowerCase()
|
||||
const existing = found.get(key)
|
||||
if (existing === undefined || existing[0] > rank) found.set(key, [rank, value])
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
if (!tagQuery && typeof room.Name === 'string') {
|
||||
const name = room.Name.toLowerCase()
|
||||
if (name.startsWith(term)) offer(room.Name, 0)
|
||||
else if (name.includes(term)) offer(room.Name, 1)
|
||||
}
|
||||
for (const tag of roomTags(room)) {
|
||||
if (tag.startsWith(term)) offer(`#${tag}`, tagQuery ? 0 : 2)
|
||||
else if (tag.includes(term)) offer(`#${tag}`, tagQuery ? 1 : 3)
|
||||
}
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.sort(([aKey, [aRank]], [bKey, [bRank]]) => aRank - bRank || aKey.localeCompare(bKey))
|
||||
.slice(0, take)
|
||||
.map(([, [, value]]) => value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
|
||||
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
|
||||
@@ -2066,5 +2186,7 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
|
||||
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
|
||||
const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
|
||||
room.SubRooms = [subRoom]
|
||||
// The template carries these (it was parsed), but a dorm minted without one wouldn't.
|
||||
attachRoomDtoDefaults(room)
|
||||
return room
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export {
|
||||
validateAndGetAccountId,
|
||||
validateAndGetRoles,
|
||||
validateAndGetVersion,
|
||||
generateToken,
|
||||
generatePhotonAuthToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
export type { PhotonAuthClaims } from './jwt'
|
||||
|
||||
+81
-2
@@ -82,6 +82,31 @@ export async function validateAndGetRoles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a request's bearer token and return its `rn.ver` claim — the game build the
|
||||
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
|
||||
* request carries no valid token, and `null` too when a valid token has no `rn.ver` (an
|
||||
* older token, issued before the claim carried the client's own value): callers fall back
|
||||
* to what they stored or to GAME_VERSION rather than writing an empty version, which
|
||||
* breaks the client's presence handling.
|
||||
*/
|
||||
export async function validateAndGetVersion(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<string | null> {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
|
||||
|
||||
const token = authHeader.slice('bearer '.length)
|
||||
try {
|
||||
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||
const version = payload['rn.ver']
|
||||
return typeof version === 'string' && version !== '' ? version : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Scopes stamped onto every token (as a claim array). */
|
||||
const TOKEN_SCOPES = [
|
||||
'profile',
|
||||
@@ -108,13 +133,63 @@ const TOKEN_SCOPES = [
|
||||
*/
|
||||
const BASE_ROLES = ['gameClient']
|
||||
|
||||
/**
|
||||
* The claims the Photon auth token carries beyond `sub`/`exp`/`aud`, describing who
|
||||
* (and on what) is connecting. All of them go on the wire as STRINGS, including the
|
||||
* numeric ones — that's how the real token encodes them.
|
||||
*/
|
||||
export interface PhotonAuthClaims {
|
||||
/** The platform-native id (e.g. a SteamID64) — `rn.platid`. */
|
||||
platformId: string
|
||||
/** PlatformType int (0 = Steam) — `rn.plat`. */
|
||||
platform: number
|
||||
/** DeviceClass int (2 = PC/standalone) — `rn.deviceclass`. */
|
||||
deviceClass: number
|
||||
/** The Photon application the token is for — the `aud` claim. */
|
||||
audience: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the short-lived HS256 token the client hands to Photon as its custom auth
|
||||
* credential (`photonAuthToken` on `GET /player/connection-info`). The claim set
|
||||
* mirrors the real one — `sub`, `rn.platid`, `rn.plat`, `rn.deviceclass`, `rn.env`,
|
||||
* `exp`, `aud` — rather than being a second copy of the login token: it identifies
|
||||
* the connecting player to the realtime server and nothing else, so none of the
|
||||
* scopes or roles from {@link generateToken} belong on it.
|
||||
*
|
||||
* Signed with the same shared `JWT_SECRET` as every other token here. A real Photon
|
||||
* Cloud application would verify this against a secret configured in its dashboard;
|
||||
* self-hosted, nothing verifies it yet — so treat it as identifying, not authorizing.
|
||||
* `rn.env` is `prod` because that's what the client is built against, regardless of
|
||||
* which environment this worker is running in.
|
||||
*/
|
||||
export async function generatePhotonAuthToken(
|
||||
accountId: number,
|
||||
claims: PhotonAuthClaims,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
sub: String(accountId),
|
||||
'rn.platid': claims.platformId,
|
||||
'rn.plat': String(claims.platform),
|
||||
'rn.deviceclass': String(claims.deviceClass),
|
||||
'rn.env': 'prod',
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
||||
aud: claims.audience,
|
||||
},
|
||||
secret
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateToken(
|
||||
accountId: string,
|
||||
platformId: string,
|
||||
platform: number,
|
||||
secret: string,
|
||||
extraRoles: string[] = [],
|
||||
privileges: string[] = []
|
||||
privileges: string[] = [],
|
||||
version: string = GAME_VERSION
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
|
||||
@@ -133,7 +208,11 @@ export async function generateToken(
|
||||
idp: 'local',
|
||||
platform,
|
||||
platform_id: platformId,
|
||||
'rn.ver': GAME_VERSION,
|
||||
// The CLIENT's build, as it posted it to /connect/token (`ver`) — not this
|
||||
// server's GAME_VERSION, which is only the fallback for a grant that names none
|
||||
// (a refresh, or a caller that isn't the game). Presence reads it back off the
|
||||
// token, so a player's reported version is the build they are actually running.
|
||||
'rn.ver': version,
|
||||
'rn.plat': platform,
|
||||
role: [...BASE_ROLES, ...extraRoles],
|
||||
// `rn.privilege` LOOKS like a scope but is a claim: the client reads it out of
|
||||
|
||||
@@ -16,7 +16,14 @@ recflare_load_env
|
||||
# custom domain via `--domain`. This keeps the real domain out of versioned files
|
||||
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
|
||||
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
|
||||
# (a JSON object, e.g. {"playersettings":"settings"}).
|
||||
# (a JSON object keyed by default subdomain, e.g. {"playersettings":"settings"}).
|
||||
#
|
||||
# The whole object also rides along as the SUBDOMAINS var, because the `ns` worker
|
||||
# has to advertise the same hosts to the client that we deploy onto here. Keying it
|
||||
# by default subdomain is what lets one .env entry do both: a worker's directory
|
||||
# name IS its default subdomain, so the lookup below and the one in ns/endpoints.ts
|
||||
# read the same key. Entries for services with no worker (e.g. "moderation") are
|
||||
# client-side redirects only — nothing here matches them.
|
||||
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
|
||||
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
||||
exit 1
|
||||
@@ -190,6 +197,7 @@ wrangler deploy \
|
||||
--var NAME:"$NAME" \
|
||||
--var SENTRY_RELEASE:"$VERSION" \
|
||||
--var DOMAIN:"$DOMAIN" \
|
||||
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
|
||||
$EXTRA_VARS \
|
||||
--domain "$HOST" \
|
||||
$MINIFY \
|
||||
|
||||
Reference in New Issue
Block a user