mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
remove unpublished rooms from catalogs
This commit is contained in:
@@ -771,7 +771,10 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
|
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
|
||||||
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
||||||
// the `createdby` variants return everything the account created.
|
// the `createdby` variants return everything the account created. None of them
|
||||||
|
// filter on Accessibility: these are the owner's own "My Rooms" lists, so a room
|
||||||
|
// they haven't published yet (a fresh clone is Private) has to show up here.
|
||||||
|
// Only the public `ownedby/:accountId` profile list is accessibility-filtered.
|
||||||
.get(
|
.get(
|
||||||
'/roomserver/rooms/createdby/me',
|
'/roomserver/rooms/createdby/me',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -795,7 +798,9 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
||||||
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
|
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
|
||||||
'`createdby/me` for everything the account created.',
|
'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
|
||||||
|
'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
|
||||||
|
'the public `ownedby/{accountId}` profile list.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
@@ -1076,8 +1081,9 @@ const app = new Hono<App>()
|
|||||||
'Copies a room’s content (scene, subrooms, settings) into a new room owned by the',
|
'Copies a room’s content (scene, subrooms, settings) into a new room owned by the',
|
||||||
'caller. Cloning is the only way to make a room, so the per-account room cap is',
|
'caller. Cloning is the only way to make a room, so the per-account room cap is',
|
||||||
'enforced here — it counts the rooms the account created, minus their auto-provisioned',
|
'enforced here — it counts the rooms the account created, minus their auto-provisioned',
|
||||||
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags and',
|
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags,',
|
||||||
'`IsRRO` cleared.',
|
'`IsRRO` cleared, and PRIVATE accessibility — a new room is unpublished until its',
|
||||||
|
'owner sets its accessibility, so it never lands in the public feeds on creation.',
|
||||||
'',
|
'',
|
||||||
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
|
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
|
||||||
'HTTP 200 with `success: false` and the message the client shows.',
|
'HTTP 200 with `success: false` and the message the client shows.',
|
||||||
|
|||||||
@@ -181,6 +181,45 @@ describe('rooms endpoints', () => {
|
|||||||
expect(other).toEqual([])
|
expect(other).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/ownedby|createdby/me lists the caller’s UNPUBLISHED rooms too', async () => {
|
||||||
|
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
|
||||||
|
// isn't public yet, or a freshly created room (which starts Private — see
|
||||||
|
// cloneRoom) would be invisible to the person who just made it. Only the
|
||||||
|
// PUBLIC-facing `ownedby/:accountId` profile list filters on accessibility.
|
||||||
|
const headers = {
|
||||||
|
...(await bearer('804')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ name: 'MyUnpublishedRoom' }).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const listOf = async (path: string) =>
|
||||||
|
(await (await SELF.fetch(`${ORIGIN}${path}`, { headers })).json()) as Array<{
|
||||||
|
Name: string
|
||||||
|
Accessibility: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/rooms/ownedby/me',
|
||||||
|
'/rooms/createdby/me',
|
||||||
|
'/roomserver/rooms/createdby/me',
|
||||||
|
]) {
|
||||||
|
const mine = await listOf(path)
|
||||||
|
const room = mine.find((r) => r.Name === 'MyUnpublishedRoom')
|
||||||
|
expect(room, `${path} must list the caller's unpublished room`).toBeDefined()
|
||||||
|
expect(room!.Accessibility).toBe(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same room is absent from the account's PUBLIC profile list.
|
||||||
|
const publicList = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)
|
||||||
|
).json()) as Array<{ Name: string }>
|
||||||
|
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
|
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -630,6 +669,7 @@ describe('rooms endpoints', () => {
|
|||||||
CreatorAccountId: number
|
CreatorAccountId: number
|
||||||
Tags?: Array<{ Tag: string }>
|
Tags?: Array<{ Tag: string }>
|
||||||
IsRRO: boolean
|
IsRRO: boolean
|
||||||
|
Accessibility: number
|
||||||
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
|
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
@@ -647,6 +687,8 @@ describe('rooms endpoints', () => {
|
|||||||
expect(ok.value!.Tags).toEqual([])
|
expect(ok.value!.Tags).toEqual([])
|
||||||
// IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
|
// IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
|
||||||
expect(ok.value!.IsRRO).toBe(false)
|
expect(ok.value!.IsRRO).toBe(false)
|
||||||
|
// A new room is unpublished: Private (0), never the source's visibility.
|
||||||
|
expect(ok.value!.Accessibility).toBe(0)
|
||||||
// Ownership is reset to the cloner: sole owner (Role 255), and none of the
|
// Ownership is reset to the cloner: sole owner (Role 255), and none of the
|
||||||
// source base room's roles (accounts 1/2) carry over.
|
// source base room's roles (accounts 1/2) carry over.
|
||||||
expect(ok.value!.Roles).toEqual([
|
expect(ok.value!.Roles).toEqual([
|
||||||
@@ -665,6 +707,40 @@ describe('rooms endpoints', () => {
|
|||||||
expect(dup.error).toMatch(/already exists/i)
|
expect(dup.error).toMatch(/already exists/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /rooms/:id/clone of a PUBLIC source stays out of the public feeds', async () => {
|
||||||
|
// Park (RoomId 25) is the one seeded base room that is itself public
|
||||||
|
// (Accessibility 1). Cloning used to inherit that, so a room appeared in
|
||||||
|
// hot/search/recommendations the instant it was created — before its owner had
|
||||||
|
// published anything.
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/25/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('802')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ name: 'ParkCloneUnpublished' }).toString(),
|
||||||
|
})
|
||||||
|
const { value } = (await res.json()) as { value: { RoomId: number; Accessibility: number } }
|
||||||
|
expect(value.Accessibility).toBe(0)
|
||||||
|
|
||||||
|
const namesIn = async (path: string) => {
|
||||||
|
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
|
||||||
|
| { Results: Array<{ Name: string }> }
|
||||||
|
| Array<{ Name: string }>
|
||||||
|
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
|
||||||
|
}
|
||||||
|
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
|
||||||
|
expect(await namesIn('/rooms/hot?tag=new&take=200')).not.toContain('ParkCloneUnpublished')
|
||||||
|
expect(await namesIn('/rooms/recommendations?take=200')).not.toContain('ParkCloneUnpublished')
|
||||||
|
expect(await namesIn('/rooms/search?query=parkcloneunpublished')).not.toContain(
|
||||||
|
'ParkCloneUnpublished'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publishing it (owner sets Accessibility to Public) puts it in the feed.
|
||||||
|
await putForm('/rooms/' + value.RoomId + '/accessibility', { accessibility: '1' }, '802')
|
||||||
|
expect(await namesIn('/rooms/hot?take=200')).toContain('ParkCloneUnpublished')
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => {
|
it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => {
|
||||||
// No Authorization header → hard 401, and nothing is created.
|
// No Authorization header → hard 401, and nothing is created.
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ export enum MessageType {
|
|||||||
* A room's (or image's) visibility, matching the client's `RoomAccessibility`. The
|
* A room's (or image's) visibility, matching the client's `RoomAccessibility`. The
|
||||||
* client declares the enum without explicit values, so these are its ordinals — and
|
* client declares the enum without explicit values, so these are its ordinals — and
|
||||||
* it sends the NAME, not the number, on the subroom accessibility route.
|
* it sends the NAME, not the number, on the subroom accessibility route.
|
||||||
|
*
|
||||||
|
* `Unlisted` is NOT a lesser `Public`: an unlisted room is open to anyone who has a
|
||||||
|
* link or an invite, it just doesn't surface in the catalogs (hot/search/
|
||||||
|
* recommendations/featured/similar, which all key on `Public`). `Private` is the
|
||||||
|
* unpublished state — a room its owner hasn't opened up at all, which is where a
|
||||||
|
* freshly cloned room starts.
|
||||||
*/
|
*/
|
||||||
export enum Accessibility {
|
export enum Accessibility {
|
||||||
Private = 0,
|
Private = 0,
|
||||||
|
|||||||
@@ -248,9 +248,10 @@ export async function isPlayerBannedFromRoom(
|
|||||||
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
||||||
* name, and the new owner. The clone starts with an empty tag set — the source's
|
* name, and the new owner. The clone starts with an empty tag set — the source's
|
||||||
* tags (including the `base` template tag) do not carry over, so the owner tags the
|
* tags (including the `base` template tag) do not carry over, so the owner tags the
|
||||||
* clone from scratch — and `IsRRO` is cleared so the client doesn't render a virtual
|
* clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual
|
||||||
* "RRO" tag on it. Returns the new room, or null when the source isn't in D1 or
|
* "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's
|
||||||
* disallows cloning.
|
* visibility. Returns the new room, or null when the source isn't in D1 or disallows
|
||||||
|
* cloning.
|
||||||
*/
|
*/
|
||||||
export async function cloneRoom(
|
export async function cloneRoom(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -284,6 +285,11 @@ export async function cloneRoom(
|
|||||||
// A user clone is not a Rec Room Original — clear the inherited flag, or the
|
// A user clone is not a Rec Room Original — clear the inherited flag, or the
|
||||||
// client renders a virtual "RRO" tag on the clone.
|
// client renders a virtual "RRO" tag on the clone.
|
||||||
IsRRO: false,
|
IsRRO: false,
|
||||||
|
// A brand-new room is unpublished: the owner publishes it by setting the room's
|
||||||
|
// accessibility. Inheriting the source's would put the clone straight into the
|
||||||
|
// public feeds (hot/search/recommendations/similar all key on Accessibility === 1)
|
||||||
|
// the moment it was made — every clone of a PUBLIC source, template or player room.
|
||||||
|
Accessibility: Accessibility.Private,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
// A fresh room has no engagement of its own — don't inherit the source's counters
|
// A fresh room has no engagement of its own — don't inherit the source's counters
|
||||||
// (the derived ones are recomputed per read, but the clone is returned as-is here).
|
// (the derived ones are recomputed per read, but the clone is returned as-is here).
|
||||||
|
|||||||
Reference in New Issue
Block a user