mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
working room saves
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
// Type-only import (erased at build) of the DO class owned by the `notify`
|
||||
// worker, so the cross-worker RPC stub is fully typed.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
|
||||
@@ -8,6 +11,9 @@ export type Env = SharedHonoEnv & {
|
||||
// the caller's current room instance for the photon access token — the
|
||||
// equivalent of the reference server's HeartbeatDB.GetPlayerHeartbeat.
|
||||
RECFLARE_MATCH_PRESENCE: KVNamespace
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push RoomUpdate notifications when a room is mutated.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -117,6 +117,73 @@ export async function setRoomName(db: D1Database, roomId: number, name: string):
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Set a room's ImageName in place (the caller is responsible for the owner check). */
|
||||
export async function setRoomImage(db: D1Database, roomId: number, imageName: string): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE rooms SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
|
||||
.bind(roomId, imageName)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Find a subroom (by SubRoomId) inside a room's `SubRooms` array, or undefined. */
|
||||
export function findSubRoom(room: Room, subRoomId: number): Record<string, unknown> | undefined {
|
||||
const subRooms = Array.isArray(room.SubRooms)
|
||||
? (room.SubRooms as Array<Record<string, unknown>>)
|
||||
: []
|
||||
return subRooms.find((s) => s.SubRoomId === subRoomId)
|
||||
}
|
||||
|
||||
/** Fields from the client's room-save POST body. */
|
||||
export interface SaveSubRoomDataInput {
|
||||
/** Uploaded blob key for this subroom's scene data (becomes the subroom's DataBlob). */
|
||||
subRoomDataFilename?: string
|
||||
/** Uploaded blob key for the room-level data. */
|
||||
roomDataFilename?: string
|
||||
description?: string
|
||||
persistenceVersion?: number
|
||||
inventionUsage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a room-save against a specific subroom: point the subroom at its newly
|
||||
* uploaded data blob (what the loader later downloads) and record the room-level
|
||||
* fields from the save. Returns the updated room, or null when the room or
|
||||
* subroom doesn't exist. The whole room JSON is rewritten (subrooms live in it).
|
||||
*/
|
||||
export async function saveSubRoomData(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
accountId: number,
|
||||
input: SaveSubRoomDataInput
|
||||
): Promise<Room | null> {
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return null
|
||||
const sub = findSubRoom(room, subRoomId)
|
||||
if (!sub) return null
|
||||
|
||||
// Populate the subroom's creator on first save — it starts null, and the
|
||||
// client NREs on a null CreatorAccountId. Only the owner reaches this path.
|
||||
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
|
||||
|
||||
// Point the subroom at the newly-uploaded data blobs and stamp the save.
|
||||
if (input.subRoomDataFilename) sub.DataBlob = input.subRoomDataFilename
|
||||
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
|
||||
sub.DataSavedAt = new Date().toISOString()
|
||||
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
|
||||
|
||||
// Room-level fields carried by the save.
|
||||
if (typeof input.description === 'string') room.Description = input.description
|
||||
if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion
|
||||
if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage
|
||||
|
||||
await db
|
||||
.prepare('UPDATE rooms SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, JSON.stringify(room))
|
||||
.run()
|
||||
return room
|
||||
}
|
||||
|
||||
interface RoomRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
+160
-1
@@ -1,11 +1,12 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import {
|
||||
cloneRoom,
|
||||
findSubRoom,
|
||||
getBaseRooms,
|
||||
getFavoritedRooms,
|
||||
getHotRooms,
|
||||
@@ -20,8 +21,10 @@ import {
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
getVisitedRooms,
|
||||
saveSubRoomData,
|
||||
searchRooms,
|
||||
setRoomDescription,
|
||||
setRoomImage,
|
||||
setRoomName,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
@@ -138,6 +141,53 @@ function unauthorized(c: Context<App>) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Room role values the reference treats as edit-capable: Creator (255) and
|
||||
* CoOwner. (CoOwner's numeric value is a best guess from the seed data — base
|
||||
* rooms give the co-owner account Role 30.)
|
||||
*/
|
||||
const EDIT_ROLES = new Set([255, 30])
|
||||
|
||||
/**
|
||||
* Whether an account may edit a room's data — its creator, or a holder of a
|
||||
* Creator/CoOwner role. Mirrors the reference's SetRoomData permission check.
|
||||
*/
|
||||
function canEditRoomData(room: Record<string, unknown>, accountId: number): boolean {
|
||||
if (room.CreatorAccountId === accountId) return true
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
|
||||
return roles.some(
|
||||
(r) => r.AccountId === accountId && typeof r.Role === 'number' && EDIT_ROLES.has(r.Role)
|
||||
)
|
||||
}
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a RoomUpdate notification to a player after their room changes, mirroring
|
||||
* the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`.
|
||||
* Hub failures are logged and swallowed — the room write has already committed,
|
||||
* so a hub hiccup must not fail the request.
|
||||
*/
|
||||
async function pushRoomUpdate(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
room: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
'RoomUpdate',
|
||||
room
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push RoomUpdate notification', {
|
||||
playerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
||||
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
|
||||
@@ -453,6 +503,115 @@ const app = new Hono<App>()
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
|
||||
// form field (a key from the storage/image upload). Business results use the
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
.put('/rooms/:roomId{[0-9]+}/image', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidImage',
|
||||
Error: 'You must provide an image!',
|
||||
})
|
||||
}
|
||||
await setRoomImage(c.env.DB, roomId, imageName)
|
||||
// Notify the owner so their client refreshes the room (RoomUpdate carries the
|
||||
// updated room). The reference sends the post-update room, so merge the change.
|
||||
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
|
||||
// array). Public — the client fetches it while loading the room. 404 when the
|
||||
// room or subroom is unknown.
|
||||
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
const sub = room ? findSubRoom(room, subRoomId) : undefined
|
||||
return sub ? c.json(sub) : c.notFound()
|
||||
})
|
||||
|
||||
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
|
||||
// by the room creator or a Creator/CoOwner role holder. Points the subroom at
|
||||
// the uploaded data blobs and records the room-level save fields, notifies the
|
||||
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
|
||||
// envelope the reference's SetRoomData uses.
|
||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (!canEditRoomData(room, accountId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
RoomData?: { Filename?: string }
|
||||
SubRoomData?: { Filename?: string }
|
||||
Description?: string
|
||||
PersistenceVersion?: number
|
||||
InventionUsage?: string
|
||||
}
|
||||
|
||||
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
||||
subRoomDataFilename: body.SubRoomData?.Filename,
|
||||
roomDataFilename: body.RoomData?.Filename,
|
||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||
persistenceVersion:
|
||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
||||
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
||||
})
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
|
||||
// itself — no envelope. The client deserializes the body directly as the subroom.
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return c.json(findSubRoom(updated, subRoomId) ?? {})
|
||||
})
|
||||
|
||||
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
|
||||
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
||||
// unknown/untagged.
|
||||
|
||||
@@ -519,6 +519,127 @@ describe('rooms endpoints', () => {
|
||||
expect(room.Description).toBe('blah blah blah')
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/image is auth-gated, owner-only, and persists', async () => {
|
||||
const imageName = '644064b03bd64a8291cde284629e9ca9.jpg'
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/image', { imageName })).status).toBe(401)
|
||||
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/image', { imageName }, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
})
|
||||
// Unknown room → Rooms.DoesntExist envelope.
|
||||
expect(await bodyOf(await putForm('/rooms/99999/image', { imageName }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
})
|
||||
// Empty image → Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/image', { imageName: ' ' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidImage',
|
||||
})
|
||||
|
||||
// Owner sets it, and it persists.
|
||||
const ok = await putForm('/rooms/2/image', { imageName }, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { ImageName: string }
|
||||
expect(room.ImageName).toBe(imageName)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => {
|
||||
// Room 2 has SubRoomId 2 in the seed.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()) as { SubRoomId: number }).toMatchObject({ SubRoomId: 2 })
|
||||
|
||||
// Unknown subroom → 404.
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/9999/data`)).status).toBe(404)
|
||||
// Unknown room → 404.
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/subrooms/2/data`)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => {
|
||||
const save = {
|
||||
UnityAssetId: null,
|
||||
RoomData: { Filename: '5c618c920f6247efb8327e327d0b4417', Hash: null, OwnershipProof: null },
|
||||
SubRoomData: {
|
||||
Filename: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
Hash: null,
|
||||
OwnershipProof: null,
|
||||
},
|
||||
InventionUsage: 'CAE=',
|
||||
PersistenceVersion: 41,
|
||||
Description: 'mydescription here',
|
||||
AutoPublish: true,
|
||||
}
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(save),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
const authed = async (roomId: number, subRoomId: number, sub = '1') =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
|
||||
body: JSON.stringify(save),
|
||||
})
|
||||
|
||||
// The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope.
|
||||
// Wrong owner (no role) → PermissionDenied.
|
||||
expect(await bodyOf(await authed(2, 2, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
})
|
||||
// Unknown room → DoesntExist.
|
||||
expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
})
|
||||
|
||||
// Owner saves → 200 with the saved SUBROOM as the bare body (no envelope),
|
||||
// carrying the new blobs and populated creator.
|
||||
const ok = await authed(2, 2, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({
|
||||
SubRoomId: 2,
|
||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
|
||||
CreatorAccountId: 1,
|
||||
PersistenceVersion: 41,
|
||||
})
|
||||
|
||||
// It also persists — the GET returns the subroom with the new blob + creator.
|
||||
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
|
||||
SubRoomId: number
|
||||
DataBlob: string
|
||||
CreatorAccountId: number
|
||||
}
|
||||
expect(sub).toMatchObject({
|
||||
SubRoomId: 2,
|
||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
CreatorAccountId: 1,
|
||||
})
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Description: string
|
||||
PersistenceVersion: number
|
||||
}
|
||||
expect(room.Description).toBe('mydescription here')
|
||||
expect(room.PersistenceVersion).toBe(41)
|
||||
|
||||
// A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200
|
||||
// with the subroom body. The creator stays account 1 (not clobbered).
|
||||
const coOwner = await authed(2, 2, '2')
|
||||
expect(coOwner.status).toBe(200)
|
||||
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
|
||||
|
||||
Reference in New Issue
Block a user