diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index cc85aa9..29133a4 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -812,6 +812,20 @@ export const AccessibilityRequest = z.object({ accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'), }) +/** + * `PUT /rooms/{roomId}/exclude` — the staff listing toggle. Not a game endpoint: the real + * service set `ExcludeFromLists` / `ExcludeFromSearch` from an admin tool the client never + * had a screen for, so this is recflare's own, called from the website's room page. + */ +export const ExcludeRequest = z.object({ + excluded: z + .string() + .describe( + '`true` hides the room from every browse feed AND from search; `false` lists it again. ' + + 'Both flags are always set together.' + ), +}) + /** * `PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility`. Unlike the room-level route * above, the client sends the enum NAME here (`accessibility=Private`), so both the name diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index f6a1315..8e9f4d4 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -113,6 +113,7 @@ import { CuratedPlaylists, DescriptionRequest, DormRoomId, + ExcludeRequest, ExperienceIncrementRequest, ExperienceRequest, FeaturedRoomGroupDto, @@ -3579,6 +3580,91 @@ const app = new Hono() } ) + // Hide a room from the feeds and from search, or list it again — the staff listing + // toggle on the website's room page. NOT a game endpoint: the real service set + // `ExcludeFromLists` / `ExcludeFromSearch` from an admin tool, and the client has no + // screen for either, so this is recflare's own and is staff-only — the owner has + // `Accessibility` for hiding their own room. The two flags are independent in the reads + // (see LISTABLE_WHERE / SEARCHABLE_WHERE) but are set TOGETHER here: a room moderation + // pulls from the listings is meant to be unfindable, not merely un-browsable. + .put( + '/rooms/:roomId{[0-9]+}/exclude', + describeRoute({ + tags: ['Custom', 'Room settings'], + summary: 'Hide a room from the listings and search (staff)', + description: [ + 'Sets `ExcludeFromLists` AND `ExcludeFromSearch` together to the posted `excluded`.', + 'Staff only — a token carrying the `developer` / `moderator` role; the room’s owner', + 'is refused (403) like anyone else, since this is a moderation action rather than a', + 'room setting. The room stays reachable by link and by matchmake; it simply stops', + 'appearing in the browse feeds, search and autocomplete. Not an endpoint the game', + 'client calls — the website’s room page uses it. Written to the audit log.', + ].join(' '), + security: AUTHED, + parameters: [roomIdParam], + requestBody: form(ExcludeRequest, 'Whether the room is hidden'), + responses: { + 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + if (!(await isStaff(c))) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const excluded = + body.excluded === 'true' || body.excluded === '1' + ? true + : body.excluded === 'false' || body.excluded === '0' + ? false + : null + if (excluded === null) { + return roomEnvelope(c, null, 'You must provide a valid excluded!') + } + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + + const updated = await updateRoomFields(c.env.DB, roomId, room, { + ExcludeFromLists: excluded, + ExcludeFromSearch: excluded, + }) + + // Always a staff action on somebody's room, so always audited — the owner can't + // get here. Written after the fact and caught, like the takedown's row: a failed + // insert must not report a change that has already happened as refused. + try { + await writeAuditLog(c.env.DB, { + playerId: accountId, + action: 'room_exclude', + data: { + roomId, + name: room.Name, + creatorAccountId: room.CreatorAccountId, + excluded, + }, + }) + } catch (err) { + logger.error('could not write an audit log row', { + action: 'room_exclude', + playerId: accountId, + roomId, + error: err instanceof Error ? err.message : String(err), + }) + } + + // The owner's client, if it's open, re-renders the room from this. + if (typeof room.CreatorAccountId === 'number') { + await pushRoomUpdate(c, room.CreatorAccountId, updated) + } + return roomEnvelope(c, updated) + } + ) + // A subroom's saved-data versions — the room-history / "restore a save" list. Every // save is its own `subroom_save` row (nothing is overwritten), so this is real // history, newest first, paged by skip/take. Auth-gated (401), and readable by the @@ -4934,6 +5020,16 @@ app.get( ].join('\n'), }, servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }], + tags: [ + { + name: 'Custom', + description: [ + 'Endpoints the game client never calls. Everything else here reimplements a', + 'call the Rec Room client makes; these are recflare’s own additions, used by the', + 'website (`www`) for things the real service did from an admin tool.', + ].join(' '), + }, + ], components: { securitySchemes: { bearerAuth: { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 039880d..b69c421 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1798,6 +1798,81 @@ describe('rooms endpoints', () => { }) }) + it('PUT /rooms/:id/exclude is the staff listing toggle — both flags, together, staff only', async () => { + // A public room its owner lists. Not a game endpoint: the real service set these two + // flags from an admin tool, so the website's room page calls this with a staff token. + await env.DB.prepare('INSERT INTO room (data) VALUES (?1)') + .bind( + JSON.stringify({ + RoomId: 9502, + Name: 'Delisted', + CreatorAccountId: 1, + IsDorm: false, + Accessibility: 1, + ImageName: '', + SubRooms: [], + }) + ) + .run() + const put = async (excluded: string, headers: Record) => + SELF.fetch(`${ORIGIN}/rooms/9502/exclude`, { + method: 'PUT', + headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, + body: `excluded=${excluded}`, + }) + const flags = async () => { + const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = 9502').first<{ + data: string + }>() + const room = JSON.parse(row!.data) as Record + return { lists: room.ExcludeFromLists, search: room.ExcludeFromSearch } + } + + // No token is a 401; the OWNER is refused like any other player — this is moderation, + // not a room setting they have. + expect((await put('true', {})).status).toBe(401) + expect((await put('true', await bearer('1', ['gameClient']))).status).toBe(403) + expect(await flags()).toEqual({ lists: undefined, search: undefined }) + + const staff = await bearer('999', ['gameClient', 'moderator']) + const hidden = await put('true', staff) + expect(hidden.status).toBe(200) + expect(await hidden.json()).toMatchObject({ + success: true, + error: '', + value: { RoomId: 9502, ExcludeFromLists: true, ExcludeFromSearch: true }, + }) + // Proper JSON booleans, not 1/0 — the virtual columns and the feeds read them as such. + expect(await flags()).toEqual({ lists: true, search: true }) + + // Hidden means hidden from search too, not just the feeds. + const search = await SELF.fetch(`${ORIGIN}/rooms/search?query=Delisted`) + const found = ((await search.json()) as { Results: Array<{ RoomId: number }> }).Results + expect(found.find((r) => r.RoomId === 9502)).toBeUndefined() + + // And it flips back, both at once. + expect(await (await put('false', staff)).json()).toMatchObject({ + success: true, + value: { ExcludeFromLists: false, ExcludeFromSearch: false }, + }) + expect(await flags()).toEqual({ lists: false, search: false }) + + // Anything but true/false is a rejection in the envelope, and writes nothing. + expect(await (await put('maybe', staff)).json()).toMatchObject({ success: false }) + expect(await flags()).toEqual({ lists: false, search: false }) + + // Each staff write is audited against the moderator, with what it did. + const { results: audit } = await env.DB.prepare( + 'SELECT player_id, data FROM audit_log WHERE action = ?1 ORDER BY audit_log_id' + ) + .bind('room_exclude') + .all<{ player_id: number; data: string }>() + expect(audit.map((row) => ({ by: row.player_id, ...JSON.parse(row.data) }))).toEqual([ + { by: 999, roomId: 9502, name: 'Delisted', creatorAccountId: 1, excluded: true }, + { by: 999, roomId: 9502, name: 'Delisted', creatorAccountId: 1, excluded: false }, + ]) + }) + it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => { type Role = { AccountId: number @@ -5999,6 +6074,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/cloning', 'PUT /rooms/{roomId}/creator', 'PUT /rooms/{roomId}/description', + 'PUT /rooms/{roomId}/exclude', 'PUT /rooms/{roomId}/image', 'PUT /rooms/{roomId}/interactionby/me/cheer', 'PUT /rooms/{roomId}/interactionby/me/favorite', diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 1d91bbb..4360672 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -135,6 +135,13 @@ interface OwnedRoom { MaxPlayers: number /** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */ CloningAllowed: boolean + /** + * The staff listing flags: out of the browse feeds, and out of search. Set together + * through the website (`PUT /rooms/{id}/exclude`); absent on a room nobody has touched, + * which reads as listed. + */ + ExcludeFromLists?: boolean + ExcludeFromSearch?: boolean SupportsScreens: boolean SupportsWalkVR: boolean SupportsTeleportVR: boolean @@ -1003,6 +1010,23 @@ async function deleteRoom(roomId: number): Promise { if (!result.Success) throw new Error(result.Error || 'The room couldn’t be deleted.') } +/** + * Hide a room from the feeds and search, or list it again — the staff toggle. Not a game + * endpoint (the real service set these flags from an admin tool), so it is recflare's own + * on `rooms`, staff-only: the owner is refused like anyone else. Both flags move together. + * Answers the lowercase `{ success, error, value }` envelope with the updated room. + */ +async function setRoomExcluded(roomId: number, excluded: boolean): Promise { + const result = await call<{ success: boolean; error: string; value: OwnedRoom | null }>( + `${where().rooms}/rooms/${roomId}/exclude`, + { method: 'PUT', authed: true, form: { excluded: String(excluded) } } + ) + if (!result.success || result.value === null) { + throw new Error(result.error || 'The room’s listing couldn’t be changed.') + } + return result.value +} + /** The `/u/` path, or null for any other path. */ function usernameFromPath(path: string): string | null { const match = /^\/u\/([^/]+)$/.exec(path) @@ -2600,6 +2624,7 @@ function RoomPage({ room={room} navigate={navigate} onTakenDown={() => setTakenDown(room.Name)} + onRoomChange={setRoom} /> ) : (

@@ -2621,10 +2646,13 @@ function PublicRoomView({ room, navigate, onTakenDown, + onRoomChange, }: { room: OwnedRoom navigate: Navigate onTakenDown: () => void + /** A staff edit answers with the updated room; the page re-renders from it. */ + onRoomChange: (room: OwnedRoom) => void }) { const [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null) const [photos, setPhotos] = useState(null) @@ -2699,10 +2727,11 @@ function PublicRoomView({ {/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's - role itself on the DELETE. */} + role itself on the DELETE and the listing PUT. */} {isAdmin() && ( <> {isDeveloper() && } + )} @@ -2786,6 +2815,61 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) { ) } +/** + * The staff listing toggle: one checkbox that sets `ExcludeFromLists` and `ExcludeFromSearch` + * together, so a room moderation pulls is gone from the feeds AND unfindable by name, not one + * without the other. The lighter hand next to the takedown — the room, its saves and its + * link all survive, it just stops being advertised. Writes on change; the room on screen is + * swapped for the one the worker answers with, so the box reflects what was stored. + */ +function StaffListing({ + room, + onRoomChange, +}: { + room: OwnedRoom + onRoomChange: (room: OwnedRoom) => void +}) { + const { pending, error, done, run } = useAction() + const hidden = room.ExcludeFromLists === true && room.ExcludeFromSearch === true + + return ( +

+

Listing

+ +

+ Keeps the room out of every browse feed, search and autocomplete. It stays reachable by link + and by invite, and its creator keeps everything. Sets both the lists and search exclusions + at once. +

+ {error &&

{error}

} + {done &&

{done}

} +
+ ) +} + function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) { const [confirming, setConfirming] = useState(false) const { pending, error, run } = useAction()