mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
add missing room save endpoint
This commit is contained in:
@@ -102,6 +102,9 @@ inconsistency here without checking the client first.
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
|
||||
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
|
||||
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
|
||||
`GET …/saves/:saveId` is the detail behind a list row, under the same creator-only gate,
|
||||
but in the CAMELCASE projection the room save's response uses — not the PascalCase rows
|
||||
the list serves. Three shapes of one save; keep them straight.
|
||||
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
|
||||
the body carries describes that one revision: `Description` is the save comment shown in
|
||||
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
|
||||
|
||||
@@ -89,6 +89,9 @@ export const roomIdParam = idParam('roomId', 'Room id')
|
||||
/** The `:subRoomId` path parameter. */
|
||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||
|
||||
/** The `:saveId` path parameter — a `subroom_save` id (globally unique, not per-subroom). */
|
||||
export const saveIdParam = idParam('saveId', 'The save’s id, as `…/saves` lists it')
|
||||
|
||||
/** The `:playerId` path parameter (an account id). */
|
||||
export const playerIdParam = idParam('playerId', 'The account whose list to read')
|
||||
|
||||
@@ -159,6 +162,11 @@ export const LoadScreenDto = z.object({
|
||||
* from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC versions,
|
||||
* no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`/`dataBlobHash`
|
||||
* that `CurrentSave` doesn't show). The two are deliberately not unified.
|
||||
*
|
||||
* Also what `GET …/subrooms/{subRoomId}/saves/{saveId}` answers — one save fetched by id
|
||||
* is the same thing the save that created it returned, so both go through
|
||||
* `toSaveResponse`. Note the `…/saves` LIST is the third shape here: it serves the raw
|
||||
* PascalCase rows ({@link SubRoomDataSaveDto}), not this.
|
||||
*/
|
||||
export const SubRoomDataSaveResponseDto = z.object({
|
||||
subRoomDataSaveId: z.int(),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaveById,
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
modifySubRoom,
|
||||
@@ -92,10 +93,12 @@ import {
|
||||
RoomLookup,
|
||||
RoomResultEnvelope,
|
||||
RoomSaveEnvelope,
|
||||
saveIdParam,
|
||||
SaveSubRoomDataRequest,
|
||||
ServiceStatus,
|
||||
stringQuery,
|
||||
SubRoomAccessibilityRequest,
|
||||
SubRoomDataSaveResponseDto,
|
||||
subRoomIdParam,
|
||||
SubRoomPermissionsRequest,
|
||||
SubRoomSavesPage,
|
||||
@@ -1929,6 +1932,52 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
|
||||
// Same gate as that list (auth-gated, creator-only): a save id resolves whether or not
|
||||
// it was ever published, so this exposes the same unpublished work the list does.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'One of a subroom’s saves by id',
|
||||
description: [
|
||||
'A single save, in the SAME camelCase projection the room save that created it',
|
||||
'returned — not the PascalCase rows `…/saves` lists. Save ids are globally',
|
||||
'unique but resolved scoped to the subroom, so one subroom cannot read another’s',
|
||||
'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown',
|
||||
'one.',
|
||||
'',
|
||||
'Creator-only, like the list it details — a save id resolves whether or not it was',
|
||||
'ever published, so this reads unpublished work.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, subRoomIdParam, saveIdParam],
|
||||
responses: {
|
||||
200: json(SubRoomDataSaveResponseDto, 'The save'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: { description: 'No such room, subroom, or save on that subroom' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const saveId = Number.parseInt(c.req.param('saveId'), 10)
|
||||
|
||||
// Scoped through the room, like the list, so a subroom id from another room can't
|
||||
// be used to read its saves.
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
|
||||
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
|
||||
|
||||
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
|
||||
return save ? c.json(toSaveResponse(save)) : 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 revision's fields against that SUBROOM,
|
||||
|
||||
@@ -2593,6 +2593,57 @@ describe('rooms endpoints', () => {
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
|
||||
const get = async (path: string, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
|
||||
|
||||
// Pick a real save off the history the previous test paged.
|
||||
const list = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
|
||||
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string; Description: string }>
|
||||
}
|
||||
const row = list.Results[0]!
|
||||
|
||||
const res = await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '1')
|
||||
expect(res.status).toBe(200)
|
||||
// The camelCase projection the room save returns — NOT the PascalCase row the list
|
||||
// serves. Same field set, exactly: no persistence/OM/UGC versions, no asset arrays.
|
||||
expect(await res.json()).toEqual({
|
||||
subRoomDataSaveId: row.SubRoomDataSaveId,
|
||||
subRoomId: 2,
|
||||
unityAssetId: null,
|
||||
unityAsset: null,
|
||||
unityAssetHash: null,
|
||||
dataBlob: row.DataBlob,
|
||||
dataBlobHash: null,
|
||||
savedByAccountId: expect.any(Number),
|
||||
savedOnPlatform: 0,
|
||||
savedOnDeviceClass: 0,
|
||||
description: row.Description,
|
||||
createdAt: expect.any(String),
|
||||
})
|
||||
|
||||
// Unknown save, and a save that exists but belongs to ANOTHER subroom (ids are
|
||||
// global, so an unscoped lookup would happily resolve this one) — both 404.
|
||||
expect((await get('/rooms/2/subrooms/2/saves/99999', '1')).status).toBe(404)
|
||||
const foreign = (
|
||||
(await subRoomOf(5, 5)) as unknown as { CurrentSave: { SubRoomDataSaveId: number } }
|
||||
).CurrentSave.SubRoomDataSaveId
|
||||
expect((await get(`/rooms/2/subrooms/2/saves/${foreign}`, '1')).status).toBe(404)
|
||||
// …and it does resolve on its own subroom, so the 404 above is the scoping, not a
|
||||
// missing row.
|
||||
expect((await get(`/rooms/5/subrooms/5/saves/${foreign}`, '1')).status).toBe(200)
|
||||
|
||||
// Unknown room or subroom is a 404 too (the LIST answers an empty page instead).
|
||||
expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404)
|
||||
expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404)
|
||||
|
||||
// Same gate as the list it details: 401 unauthed, 403 for a non-creator, and 403
|
||||
// even for a co-owner — it reads unpublished saves.
|
||||
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`)).status).toBe(401)
|
||||
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '999')).status).toBe(403)
|
||||
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '2')).status).toBe(403)
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -2640,6 +2691,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/{roomId}/playerdata/me',
|
||||
'GET /rooms/{roomId}/similar',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
|
||||
'GET /roomserver/rooms/createdby/me',
|
||||
'POST /rooms/{roomId}/bans',
|
||||
'POST /rooms/{roomId}/clone',
|
||||
|
||||
Reference in New Issue
Block a user