mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[rooms] admin switch to exclude rooms from lists
This commit is contained in:
@@ -812,6 +812,20 @@ export const AccessibilityRequest = z.object({
|
|||||||
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
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
|
* `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
|
* above, the client sends the enum NAME here (`accessibility=Private`), so both the name
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
CuratedPlaylists,
|
CuratedPlaylists,
|
||||||
DescriptionRequest,
|
DescriptionRequest,
|
||||||
DormRoomId,
|
DormRoomId,
|
||||||
|
ExcludeRequest,
|
||||||
ExperienceIncrementRequest,
|
ExperienceIncrementRequest,
|
||||||
ExperienceRequest,
|
ExperienceRequest,
|
||||||
FeaturedRoomGroupDto,
|
FeaturedRoomGroupDto,
|
||||||
@@ -3579,6 +3580,91 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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<string, unknown>
|
||||||
|
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
|
// 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
|
// 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
|
// history, newest first, paged by skip/take. Auth-gated (401), and readable by the
|
||||||
@@ -4934,6 +5020,16 @@ app.get(
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }],
|
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: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
bearerAuth: {
|
bearerAuth: {
|
||||||
|
|||||||
@@ -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<string, string>) =>
|
||||||
|
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<string, unknown>
|
||||||
|
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 () => {
|
it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {
|
||||||
type Role = {
|
type Role = {
|
||||||
AccountId: number
|
AccountId: number
|
||||||
@@ -5999,6 +6074,7 @@ describe('rooms endpoints', () => {
|
|||||||
'PUT /rooms/{roomId}/cloning',
|
'PUT /rooms/{roomId}/cloning',
|
||||||
'PUT /rooms/{roomId}/creator',
|
'PUT /rooms/{roomId}/creator',
|
||||||
'PUT /rooms/{roomId}/description',
|
'PUT /rooms/{roomId}/description',
|
||||||
|
'PUT /rooms/{roomId}/exclude',
|
||||||
'PUT /rooms/{roomId}/image',
|
'PUT /rooms/{roomId}/image',
|
||||||
'PUT /rooms/{roomId}/interactionby/me/cheer',
|
'PUT /rooms/{roomId}/interactionby/me/cheer',
|
||||||
'PUT /rooms/{roomId}/interactionby/me/favorite',
|
'PUT /rooms/{roomId}/interactionby/me/favorite',
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ interface OwnedRoom {
|
|||||||
MaxPlayers: number
|
MaxPlayers: number
|
||||||
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
|
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
|
||||||
CloningAllowed: boolean
|
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
|
SupportsScreens: boolean
|
||||||
SupportsWalkVR: boolean
|
SupportsWalkVR: boolean
|
||||||
SupportsTeleportVR: boolean
|
SupportsTeleportVR: boolean
|
||||||
@@ -1003,6 +1010,23 @@ async function deleteRoom(roomId: number): Promise<void> {
|
|||||||
if (!result.Success) throw new Error(result.Error || 'The room couldn’t be deleted.')
|
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<OwnedRoom> {
|
||||||
|
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/<username>` path, or null for any other path. */
|
/** The `/u/<username>` path, or null for any other path. */
|
||||||
function usernameFromPath(path: string): string | null {
|
function usernameFromPath(path: string): string | null {
|
||||||
const match = /^\/u\/([^/]+)$/.exec(path)
|
const match = /^\/u\/([^/]+)$/.exec(path)
|
||||||
@@ -2600,6 +2624,7 @@ function RoomPage({
|
|||||||
room={room}
|
room={room}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onTakenDown={() => setTakenDown(room.Name)}
|
onTakenDown={() => setTakenDown(room.Name)}
|
||||||
|
onRoomChange={setRoom}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -2621,10 +2646,13 @@ function PublicRoomView({
|
|||||||
room,
|
room,
|
||||||
navigate,
|
navigate,
|
||||||
onTakenDown,
|
onTakenDown,
|
||||||
|
onRoomChange,
|
||||||
}: {
|
}: {
|
||||||
room: OwnedRoom
|
room: OwnedRoom
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onTakenDown: () => void
|
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 [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null)
|
||||||
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
|
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
|
||||||
@@ -2699,10 +2727,11 @@ function PublicRoomView({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
|
{/* 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() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
{isDeveloper() && <StaffRoomTokens room={room} />}
|
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||||
|
<StaffListing room={room} onRoomChange={onRoomChange} />
|
||||||
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Listing</h2>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hidden}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => {
|
||||||
|
const excluded = e.target.checked
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await setRoomExcluded(room.RoomId, excluded)
|
||||||
|
// Only the flags are taken: the envelope's room is the stored record,
|
||||||
|
// without the live counters the public lookup folds in.
|
||||||
|
onRoomChange({
|
||||||
|
...room,
|
||||||
|
ExcludeFromLists: updated.ExcludeFromLists === true,
|
||||||
|
ExcludeFromSearch: updated.ExcludeFromSearch === true,
|
||||||
|
})
|
||||||
|
return excluded
|
||||||
|
? `^${room.Name} is hidden from the listings and search.`
|
||||||
|
: `^${room.Name} is listed again.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Hide from listings and search
|
||||||
|
</label>
|
||||||
|
<p className="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const { pending, error, run } = useAction()
|
const { pending, error, run } = useAction()
|
||||||
|
|||||||
Reference in New Issue
Block a user