diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 40a40cc..7f569fc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,7 +2,21 @@ // origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer // token. import { Elysia, t } from "elysia"; -import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db"; +import { + and, + desc, + eq, + ilike, + inArray, + ne, + or, + sql, + count, + schema, + type Database, + type ParentInfo, + type RoomModerator, +} from "@t5de/db"; import type { Auth } from "./auth"; import { AgeError, AgeService } from "./age"; import { imvuAgeProvider, imvuResult } from "./age/imvu"; @@ -11,7 +25,19 @@ import { Realtime } from "./realtime"; import { isAdultRoom, Rooms } from "./rooms"; import { Signer } from "./keys"; import { ProductError, Products } from "./products"; -import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted"; +import { + chatId, + HostedError, + HostedRooms, + instanceId, + maxImageBytes, + maxModerators, + maxPlacement, + maxSceneItems, + roomInfo, + type RoomChanges, + type Viewer, +} from "./hosted"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; const { user, session, imvuLink, linkChallenge, audit } = schema; @@ -82,6 +108,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const rooms = roomsService ?? new Rooms(db, imvu); const hosted = new HostedRooms(db); const decorations = new Decorations(db); + const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, ""); const realtime = rt ?? new Realtime({ @@ -94,6 +121,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms rooms.seen(instanceId).catch((e) => console.error("rooms:", e)); }, hostedRoom: (n) => hosted.info(n), + hostedBanned: (n, imvuId) => hosted.banned(n, imvuId), + hostedBoot: async (b) => { + if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes); + await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), { + imvu: b.imvuId, + by: b.by, + ...(b.ban ? { minutes: b.minutes } : {}), + }); + }, + accountName: async (imvuId) => { + const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId)); + return l?.username ?? ""; + }, + hostedVisit: (userId, n, imvuId) => { + hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e)); + }, }); const limiter = new Limiter(); @@ -225,7 +268,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms } }; - const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({ + type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }; + type Extras = { favorite: boolean; score: number; ratings: number }; + + const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({ n: r.n, instanceId: instanceId(r.ownerImvuId, r.n), chatId: chatId(r.n), @@ -237,11 +283,77 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms ownerImvuId: r.ownerImvuId, ownerName, occupancy: realtime.occupancy(r.n), + privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private", + moderators: r.moderators.map((m) => m.imvuId), + moderatorNames: r.moderators.map((m) => m.username), + image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null, + favorite: extras.favorite, + score: extras.score, + ratings: extras.ratings, createdAt: r.createdAt.toISOString(), }); + /** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */ + const hostedViews = async (rows: HostedRow[], person: Person | undefined) => { + const ns = rows.map((r) => r.room.n); + const [scores, favorites] = await Promise.all([ + hosted.scores(ns), + person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set()), + ]); + return rows.map((r) => + hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }), + ); + }; + const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!; + + /** The person and their linked IMVU accounts. */ + const viewerOf = async (p: Person): Promise => ({ + id: p.id, + imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id), + }); + + /** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */ + const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => { + if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`); + const out: RoomModerator[] = []; + for (const e of entries) { + const text = typeof e === "string" ? e.trim() : ""; + const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0; + if (id > 0) { + const name = known.find((m) => m.imvuId === id)?.username; + const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id)); + out.push({ imvuId: id, username: name ?? l?.username ?? "" }); + continue; + } + if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs."); + const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase()); + if (k) { + out.push(k); + continue; + } + const [l] = await db + .select({ imvuId: imvuLink.imvuId, username: imvuLink.username }) + .from(imvuLink) + .where(sql`lower(${imvuLink.username}) = lower(${text})`); + if (l) { + out.push(l); + continue; + } + try { + const found = await imvuCall(me, () => imvu.byUsername(text)); + out.push({ imvuId: found.id, username: found.username }); + } catch (err) { + if (err instanceof Problem && err.slug === "no_such_imvu_account") + throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`); + throw err; + } + } + return out; + }; + const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) }); const rating = t.Union([t.Literal("general"), t.Literal("adult")]); + const privacy = t.Union([t.Literal("public"), t.Literal("private")]); const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }); const maxUsers = t.Integer({ minimum: 2, maximum: 30 }); const sceneItem = t.Tuple([ @@ -628,7 +740,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000)) throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while."); const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body)); - await audited(me.id, "hosted_room.create", String(r.room.n), { name: r.room.name, rating: r.room.rating }); + await audited(me.id, "hosted_room.create", String(r.room.n), { + name: r.room.name, + rating: r.room.rating, + privacy: r.room.privacy, + }); set.status = 201; return { room: hostedView(r) }; }, @@ -641,34 +757,64 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms rating, maxUsers: t.Optional(maxUsers), imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), + privacy: t.Optional(privacy), }), }, ) .get( "/hosted-rooms", async ({ person, query }) => { - const mine = query.mine === "1" || query.mine === "true"; - if (mine && !person) throw new Problem(401, "signed_out", "Sign in first."); - const rows = await hosted.list({ adult: await personAdult(person), mine: mine ? person!.id : undefined }); - return { rooms: rows.map(hostedView) }; + const on = (v?: string) => v === "1" || v === "true"; + const mine = on(query.mine); + const favorites = on(query.favorites); + const recent = on(query.recent); + if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first."); + const rows = await hosted.list({ + adult: await personAdult(person), + viewer: person ? await viewerOf(person) : undefined, + mine, + favorites, + recent, + }); + return { rooms: await hostedViews(rows, person) }; + }, + { + query: t.Object({ + mine: t.Optional(t.String({ maxLength: 8 })), + favorites: t.Optional(t.String({ maxLength: 8 })), + recent: t.Optional(t.String({ maxLength: 8 })), + }), }, - { query: t.Object({ mine: t.Optional(t.String({ maxLength: 8 })) }) }, ) .get( "/hosted-rooms/:n", async ({ person, params }) => { const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id)); - return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } }; + return { + room: await hostedViewFor(r, person), + scene: { contents: r.room.scene, hash: r.room.sceneHash }, + myRating: person ? await hosted.myRating(person.id, r.room.n) : 0, + }; }, { params: hostedParam }, ) .patch( "/hosted-rooms/:n", async ({ me, params, body }) => { - const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, body)); - realtime.hostedChanged(r.room.n, { closed: false, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers }); - await audited(me.id, "hosted_room.edit", String(r.room.n), { fields: Object.keys(body) }); - return { room: hostedView(r) }; + const { moderators: given, ...rest } = body; + const changes: RoomChanges = given + ? { + ...rest, + moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators), + } + : rest; + const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes)); + realtime.hostedChanged(r.room.n, roomInfo(r)); + await audited(me.id, "hosted_room.edit", String(r.room.n), { + fields: Object.keys(body), + ...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}), + }); + return { room: await hostedViewFor(r, me) }; }, { signedIn: true, @@ -679,6 +825,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms rating: t.Optional(rating), maxUsers: t.Optional(maxUsers), roomPid: t.Optional(roomPid), + privacy: t.Optional(privacy), + moderators: t.Optional( + t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), { + maxItems: maxModerators, + }), + ), }), }, ) @@ -707,6 +859,134 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms { signedIn: true, params: hostedParam }, ) + // Phase 2: bans, images, favorites, ratings. + .get( + "/hosted-rooms/:n/participants", + async ({ person, params }) => { + const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id)); + return { participants: realtime.participants(r.room.n) }; + }, + { params: hostedParam }, + ) + .get( + "/hosted-rooms/:n/bans", + async ({ me, params }) => { + await hostedCall(async () => hosted.managed(await viewerOf(me), params.n)); + const bans = await hosted.bans(params.n); + return { + bans: bans.map((b) => ({ + imvuId: b.imvuId, + byImvuId: b.byImvuId, + until: b.until?.toISOString() ?? null, + createdAt: b.createdAt.toISOString(), + })), + }; + }, + { signedIn: true, params: hostedParam }, + ) + .delete( + "/hosted-rooms/:n/bans/:imvu", + async ({ me, params, set }) => { + if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu)))) + throw new Problem(404, "not_found", "That account isn't banned here."); + await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu }); + set.status = 204; + }, + { + signedIn: true, + params: t.Object({ + n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }), + imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }), + }), + }, + ) + .get( + "/hosted-rooms/:n/image", + async ({ person, params, request }) => { + const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id)); + const img = r.room.imageHash ? await hosted.image(params.n) : undefined; + if (!img) throw new Problem(404, "not_found", "This room has no image."); + const etag = `"${img.hash}"`; + const headers = { + "Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`, + ETag: etag, + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + }; + if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers }); + return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } }); + }, + { params: hostedParam }, + ) + .put( + "/hosted-rooms/:n/image", + async ({ me, params, body }) => { + if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000)) + throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while."); + if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB."); + const data = Buffer.from(await body.image.arrayBuffer()); + const r = await hostedCall(() => hosted.setImage(me.id, params.n, data)); + await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length }); + return { room: await hostedViewFor(r, me) }; + }, + { + signedIn: true, + params: hostedParam, + body: t.Object({ image: t.File() }), + beforeHandle({ request }) { + // Refuse big uploads before reading them (with room for the multipart framing). + if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024) + throw new Problem(400, "invalid", "An image is up to 512 KB."); + }, + }, + ) + .delete( + "/hosted-rooms/:n/image", + async ({ me, params }) => { + const r = await hostedCall(() => hosted.removeImage(me.id, params.n)); + await audited(me.id, "hosted_room.image", String(params.n), { removed: true }); + return { room: await hostedViewFor(r, me) }; + }, + { signedIn: true, params: hostedParam }, + ) + .put( + "/hosted-rooms/:n/favorite", + async ({ me, params, set }) => { + if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little."); + await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id)); + await hosted.favorite(me.id, params.n, true); + set.status = 204; + }, + { signedIn: true, params: hostedParam }, + ) + .delete( + "/hosted-rooms/:n/favorite", + async ({ me, params, set }) => { + await hosted.favorite(me.id, params.n, false); + set.status = 204; + }, + { signedIn: true, params: hostedParam }, + ) + .put( + "/hosted-rooms/:n/rating", + async ({ me, params, body }) => { + if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little."); + const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id)); + if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room."); + await hosted.rate(me.id, params.n, body.score); + return { room: await hostedViewFor(r, me), myRating: body.score }; + }, + { signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) }, + ) + .delete( + "/hosted-rooms/:n/rating", + async ({ me, params, set }) => { + await hosted.rate(me.id, params.n, null); + set.status = 204; + }, + { signedIn: true, params: hostedParam }, + ) + // Decorations over IMVU rooms (docs/products.md, "Decorations"). .get( "/decorations", diff --git a/apps/api/src/hosted.ts b/apps/api/src/hosted.ts index cfdd3e7..e6fe354 100644 --- a/apps/api/src/hosted.ts +++ b/apps/api/src/hosted.ts @@ -1,16 +1,35 @@ // T5DE-hosted rooms (docs/hosted-rooms.md): made on the website, joined through T5DE's proxy. The routes are in -// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves. +// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves, and (phase 2) their moderators, bans, +// images, and people's favorites, ratings and visits. // // A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they // aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused. import { createHash } from "node:crypto"; -import { and, count, desc, eq, sql, schema, type Database, type SceneItem } from "@t5de/db"; +import { + and, + avg, + count, + desc, + eq, + gt, + inArray, + isNull, + notInArray, + or, + sql, + schema, + type Database, + type RoomModerator, + type SceneItem, +} from "@t5de/db"; import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime"; -const { hostedRoom, imvuLink, user } = schema; +const { hostedRoom, hostedRoomBan, hostedRoomFavorite, hostedRoomImage, hostedRoomRating, hostedRoomVisit, imvuLink, user } = + schema; export type HostedRoom = typeof hostedRoom.$inferSelect; export type Rating = "general" | "adult"; +export type Privacy = "public" | "private"; /** An error for the person; the routes turn it into a Problem. */ export class HostedError extends Error { @@ -26,6 +45,10 @@ export class HostedError extends Error { export const maxRoomsPerPerson = 10; export const maxSceneItems = 500; export const maxPlacement = 200; +export const maxModerators = 20; +export const maxImageBytes = 512 * 1024; +const visitsKept = 50; +const recentShown = 20; /** The scene's hash, which the client caches scenes by: of the room shell and its contents. */ export const sceneHash = (roomPid: number, contents: SceneItem[]) => @@ -51,6 +74,14 @@ function cleanDescription(s: string): string { return v; } +/** An image's content type by its magic bytes: PNG or JPEG only. */ +export function imageType(b: Uint8Array): "image/png" | "image/jpeg" | undefined { + const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (b.length > 24 && png.every((v, i) => b[i] === v)) return "image/png"; + if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; + return undefined; +} + export type NewRoom = { name: string; description?: string; @@ -58,9 +89,23 @@ export type NewRoom = { rating: Rating; maxUsers?: number; imvuId: number; + privacy?: Privacy; }; -export type RoomChanges = Partial>; +export type RoomChanges = Partial> & { + /** Already resolved to accounts (app.ts resolves names). */ + moderators?: RoomModerator[]; +}; + +type Row = { room: HostedRoom; ownerName: string }; + +/** Who's looking: the person and their linked IMVU accounts (for private rooms they moderate). */ +export type Viewer = { id: string; imvuIds: number[] }; + +export const isRoomModerator = (r: HostedRoom, imvuIds: Iterable) => { + const ids = new Set(imvuIds); + return r.moderators.some((m) => ids.has(m.imvuId)); +}; export class HostedRooms { constructor(private readonly db: Database) {} @@ -77,24 +122,58 @@ export class HostedRooms { private visible = sql`coalesce(${user.banned}, false) = false`; - /** Open rooms, newest first: all those the viewer may see, or (mine) the viewer's own. */ - async list({ adult, mine }: { adult: boolean; mine?: string }) { - const rows = await this.shown() - .where( - and( - this.visible, - eq(hostedRoom.closed, false), - mine ? eq(hostedRoom.ownerId, mine) : undefined, - adult || mine ? undefined : eq(hostedRoom.rating, "general"), - ), - ) + /** Public rooms, and private ones the viewer owns or moderates. */ + private listable(viewer?: Viewer) { + if (!viewer) return eq(hostedRoom.privacy, "public"); + const mods = viewer.imvuIds.length + ? sql`exists (select 1 from jsonb_array_elements(${hostedRoom.moderators}) m where (m->>'imvuId')::bigint in (${sql.join( + viewer.imvuIds.map((id) => sql`${id}`), + sql`, `, + )}))` + : undefined; + return or(eq(hostedRoom.privacy, "public"), eq(hostedRoom.ownerId, viewer.id), mods); + } + + /** + * Open rooms the viewer may see: listed ones (public, and private ones they own or moderate), newest first; or + * (mine) their own; (favorites) their favorites, latest first; (recent) the last 20 they joined, latest first. + */ + async list({ + adult, + viewer, + mine, + favorites, + recent, + }: { + adult: boolean; + viewer?: Viewer; + mine?: boolean; + favorites?: boolean; + recent?: boolean; + }): Promise { + const open = and(this.visible, eq(hostedRoom.closed, false), adult || mine ? undefined : eq(hostedRoom.rating, "general")); + if (viewer && favorites) { + return this.shown() + .innerJoin(hostedRoomFavorite, and(eq(hostedRoomFavorite.n, hostedRoom.n), eq(hostedRoomFavorite.userId, viewer.id))) + .where(open) + .orderBy(desc(hostedRoomFavorite.createdAt), desc(hostedRoom.n)) + .limit(200); + } + if (viewer && recent) { + return this.shown() + .innerJoin(hostedRoomVisit, and(eq(hostedRoomVisit.n, hostedRoom.n), eq(hostedRoomVisit.userId, viewer.id))) + .where(open) + .orderBy(desc(hostedRoomVisit.at)) + .limit(recentShown); + } + return this.shown() + .where(and(open, mine && viewer ? eq(hostedRoom.ownerId, viewer.id) : this.listable(viewer))) .orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n)) .limit(200); - return rows; } /** A room as shown to a viewer; closed rooms are 410, hidden and adult ones (for others) 404. */ - async get(n: number, adult: boolean, viewerId?: string) { + async get(n: number, adult: boolean, viewerId?: string): Promise { if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room."); const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n))); if (!r) throw new HostedError(404, "not_found", "No such room."); @@ -107,8 +186,7 @@ export class HostedRooms { /** What realtime needs to let someone in (undefined: no such room, or it's hidden). */ async info(n: number): Promise { const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n))); - if (!r) return undefined; - return { closed: r.room.closed, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers }; + return r ? roomInfo(r) : undefined; } async create(ownerId: string, adultPerson: boolean, body: NewRoom) { @@ -138,6 +216,7 @@ export class HostedRooms { roomPid: body.roomPid, rating: body.rating, maxUsers: body.maxUsers ?? 10, + privacy: body.privacy ?? "public", scene: [], sceneHash: sceneHash(body.roomPid, []), }) @@ -147,7 +226,7 @@ export class HostedRooms { } /** The owner's room, open (for changes). */ - private async own(ownerId: string, n: number) { + async own(ownerId: string, n: number) { const r = await this.get(n, true, ownerId); if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this."); return r; @@ -158,6 +237,13 @@ export class HostedRooms { if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson) throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account."); const roomPid = changes.roomPid ?? r.roomPid; + let moderators: RoomModerator[] | undefined; + if (changes.moderators) { + const seen = new Set([r.ownerImvuId]); // the owner isn't listed as a moderator of their own room + moderators = changes.moderators.filter((m) => !seen.has(m.imvuId) && seen.add(m.imvuId)); + if (moderators.length > maxModerators) + throw new HostedError(400, "invalid", `A room has up to ${maxModerators} moderators.`); + } const [updated] = await this.db .update(hostedRoom) .set({ @@ -165,6 +251,8 @@ export class HostedRooms { ...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}), ...(changes.rating !== undefined ? { rating: changes.rating } : {}), ...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}), + ...(changes.privacy !== undefined ? { privacy: changes.privacy } : {}), + ...(moderators !== undefined ? { moderators } : {}), ...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}), }) .where(eq(hostedRoom.n, n)) @@ -190,4 +278,160 @@ export class HostedRooms { await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n)); return r; } + + // ---- Bans ---- + + /** A room the viewer owns or moderates (for its bans). */ + async managed(viewer: Viewer, n: number) { + const r = await this.get(n, true, viewer.id); + if (r.room.ownerId !== viewer.id && !isRoomModerator(r.room, viewer.imvuIds)) + throw new HostedError(403, "forbidden", "Only the room's owner and moderators can do this."); + return r; + } + + async ban(n: number, imvuId: number, byImvuId: number, minutes: number) { + const until = minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null; + await this.db + .insert(hostedRoomBan) + .values({ n, imvuId, byImvuId, until }) + .onConflictDoUpdate({ target: [hostedRoomBan.n, hostedRoomBan.imvuId], set: { byImvuId, until, createdAt: new Date() } }); + } + + private inForce = () => or(isNull(hostedRoomBan.until), gt(hostedRoomBan.until, new Date())); + + async banned(n: number, imvuId: number): Promise { + const [b] = await this.db + .select({ n: hostedRoomBan.n }) + .from(hostedRoomBan) + .where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId), this.inForce())); + return b !== undefined; + } + + /** A room's bans in force, latest first. */ + async bans(n: number) { + return this.db + .select() + .from(hostedRoomBan) + .where(and(eq(hostedRoomBan.n, n), this.inForce())) + .orderBy(desc(hostedRoomBan.createdAt)); + } + + /** Lifts a ban (owner only); false if there was none. */ + async unban(ownerId: string, n: number, imvuId: number): Promise { + await this.own(ownerId, n); + const gone = await this.db + .delete(hostedRoomBan) + .where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId))) + .returning(); + return gone.length > 0; + } + + // ---- Images ---- + + async setImage(ownerId: string, n: number, data: Buffer) { + const { ownerName } = await this.own(ownerId, n); + if (data.length > maxImageBytes) throw new HostedError(400, "invalid", "An image is up to 512 KB."); + const contentType = imageType(data); + if (!contentType) throw new HostedError(400, "invalid", "An image is a PNG or JPEG."); + const hash = createHash("sha256").update(data).digest("hex"); + const room = await this.db.transaction(async (tx) => { + await tx + .insert(hostedRoomImage) + .values({ n, contentType, data, hash }) + .onConflictDoUpdate({ target: hostedRoomImage.n, set: { contentType, data, hash, updatedAt: new Date() } }); + const [r] = await tx.update(hostedRoom).set({ imageHash: hash }).where(eq(hostedRoom.n, n)).returning(); + return r!; + }); + return { room, ownerName }; + } + + async removeImage(ownerId: string, n: number) { + const { ownerName } = await this.own(ownerId, n); + await this.db.delete(hostedRoomImage).where(eq(hostedRoomImage.n, n)); + const [room] = await this.db.update(hostedRoom).set({ imageHash: null }).where(eq(hostedRoom.n, n)).returning(); + return { room: room!, ownerName }; + } + + async image(n: number) { + const [i] = await this.db.select().from(hostedRoomImage).where(eq(hostedRoomImage.n, n)); + return i; + } + + // ---- Favorites, ratings, visits ---- + + async favorite(userId: string, n: number, on: boolean) { + if (on) await this.db.insert(hostedRoomFavorite).values({ userId, n }).onConflictDoNothing(); + else await this.db.delete(hostedRoomFavorite).where(and(eq(hostedRoomFavorite.userId, userId), eq(hostedRoomFavorite.n, n))); + } + + /** Which of these rooms are the person's favorites. */ + async favoriteSet(userId: string, ns: number[]): Promise> { + if (ns.length === 0) return new Set(); + const rows = await this.db + .select({ n: hostedRoomFavorite.n }) + .from(hostedRoomFavorite) + .where(and(eq(hostedRoomFavorite.userId, userId), inArray(hostedRoomFavorite.n, ns))); + return new Set(rows.map((r) => r.n)); + } + + /** Sets (1-5) or removes (null) the person's rating. */ + async rate(userId: string, n: number, score: number | null) { + if (score === null) { + await this.db.delete(hostedRoomRating).where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n))); + return; + } + await this.db + .insert(hostedRoomRating) + .values({ userId, n, score }) + .onConflictDoUpdate({ target: [hostedRoomRating.userId, hostedRoomRating.n], set: { score, updatedAt: new Date() } }); + } + + async myRating(userId: string, n: number): Promise { + const [r] = await this.db + .select({ score: hostedRoomRating.score }) + .from(hostedRoomRating) + .where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n))); + return r?.score ?? 0; + } + + /** Each room's average rating (0 if none, two decimals) and how many rated it. */ + async scores(ns: number[]): Promise> { + if (ns.length === 0) return new Map(); + const rows = await this.db + .select({ n: hostedRoomRating.n, score: avg(hostedRoomRating.score), ratings: count() }) + .from(hostedRoomRating) + .where(inArray(hostedRoomRating.n, ns)) + .groupBy(hostedRoomRating.n); + return new Map(rows.map((r) => [r.n, { score: Math.round(Number(r.score ?? 0) * 100) / 100, ratings: r.ratings }])); + } + + /** Records a visit (a join), keeping the person's latest 50. */ + async visit(userId: string, n: number, imvuId: number) { + const at = new Date(); + await this.db + .insert(hostedRoomVisit) + .values({ userId, n, imvuId, at }) + .onConflictDoUpdate({ target: [hostedRoomVisit.userId, hostedRoomVisit.n], set: { imvuId, at } }); + const keep = this.db + .select({ n: hostedRoomVisit.n }) + .from(hostedRoomVisit) + .where(eq(hostedRoomVisit.userId, userId)) + .orderBy(desc(hostedRoomVisit.at)) + .limit(visitsKept); + await this.db.delete(hostedRoomVisit).where(and(eq(hostedRoomVisit.userId, userId), notInArray(hostedRoomVisit.n, keep))); + } +} + +/** What realtime needs of a room. */ +export function roomInfo({ room: r, ownerName }: Row): HostedRoomInfo { + return { + closed: r.closed, + adult: r.rating === "adult", + maxUsers: r.maxUsers, + privacy: r.privacy === "private" ? "private" : "public", + ownerImvuId: r.ownerImvuId, + moderators: r.moderators.map((m) => m.imvuId), + name: r.name, + ownerName, + }; } diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index f574bb2..583f39d 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -24,7 +24,7 @@ // proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."} // to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU // account is dropped, and so is anything over the rate (bursts of 20, then 10 a second) -// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat"}], +// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}], // "sequence": s} (the latest message number) // hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" | // "not_linked" | "too_many", "message": "..."} @@ -32,24 +32,50 @@ // hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat // hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account // was unlinked or changed, or the room became adult) +// +// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail): +// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room +// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator +// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?} +// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions +// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X} +// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?} +// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X) +// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X) +// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true} +// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100) +// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]} export type Item = { id: string; name: string; adult: boolean }; export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] }; /** What realtime needs to know of a hosted room to let someone in. */ -export type HostedRoomInfo = { closed: boolean; adult: boolean; maxUsers: number }; +export type HostedRoomInfo = { + closed: boolean; + adult: boolean; + maxUsers: number; + privacy: "public" | "private"; + ownerImvuId: number; + moderators: number[]; + name: string; + ownerName: string; +}; + +/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */ +export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number }; /** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */ export const hostedBase = 2_100_000_000; /** The highest room number: chat IDs must stay XML-RPC integers. */ export const maxHostedN = 2_147_483_647 - hostedBase; -type Participant = { imvu_id: number; seat: number }; +type Participant = { imvu_id: number; seat: number; name: string }; // requested: the IMVU account the client said it's using, even before it's linked (it counts once it is). // chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told. -// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. +// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it +// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites. type Session = { userId: string; imvuId: number; @@ -62,13 +88,21 @@ type Session = { hosted: Set; tokens: number; refilled: number; + watching: Set; + watchGen: number; + invites: number; + invitesRefilled: number; send: (message: object) => void; close: () => void; }; // A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while // the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes. -type Hosted = { members: Map; sequence: number }; +// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted. +type Hosted = { members: Map; sequence: number; admitted: Set }; + +// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only. +type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean }; export type RealtimeDeps = { /** The person an IMVU account is linked to. */ @@ -79,6 +113,14 @@ export type RealtimeDeps = { room?: (instanceId: string) => void; /** A hosted room (undefined: there's none by that number). */ hostedRoom?: (n: number) => Promise; + /** Whether an IMVU account is banned from a hosted room now. */ + hostedBanned?: (n: number, imvuId: number) => Promise; + /** Someone was booted (and maybe banned): store the ban, log it. */ + hostedBoot?: (boot: Boot) => Promise; + /** A person joined a hosted room as that IMVU account (for their recent rooms). */ + hostedVisit?: (userId: string, n: number, imvuId: number) => void; + /** The IMVU username of a linked account, from its verified link ("" if unknown). */ + accountName?: (imvuId: number) => Promise; }; const maxChats = 16; @@ -89,6 +131,12 @@ const maxHostedPerSession = 5; const maxRoomMessage = 64 * 1024; // bytes, as UTF-8 const sendBurst = 20; const sendPerSecond = 10; +const maxWatched = 100; +const inviteLifetime = 10 * 60 * 1000; +const inviteBurst = 10; +const invitesPerMinute = 10; +const maxNote = 200; +const maxBanMinutes = 10 * 366 * 24 * 60; const roomErrors = { not_linked: "Sign in to T5DE with a linked IMVU account first.", @@ -97,17 +145,42 @@ const roomErrors = { adult: "This room is for adults.", full: "This room is full.", too_many: "You're in too many rooms.", + banned: "You're banned from this room.", + private: "This room is private.", +} as const; + +// Reasons in room.boot_result and room.invite_result. +const actionErrors = { + not_linked: roomErrors.not_linked, + not_found: roomErrors.not_found, + closed: roomErrors.closed, + forbidden: "You can't do that in this room.", + not_in_room: "Join the room first.", + banned: "They're banned from this room.", + adult: "This room is for adults.", + rate_limited: "Too many invitations. Try again in a minute.", } as const; export class Realtime { private sessions = new Map(); private hosted = new Map(); + private watchers = new Map>(); + private invites = new Map(); private listeners = new Set<() => void>(); private readonly deps: Required; constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) { const d = typeof deps === "function" ? { owner: deps } : deps; - this.deps = { adult: async () => false, room: () => {}, hostedRoom: async () => undefined, ...d }; + this.deps = { + adult: async () => false, + room: () => {}, + hostedRoom: async () => undefined, + hostedBanned: async () => false, + hostedBoot: async () => {}, + hostedVisit: () => {}, + accountName: async () => "", + ...d, + }; } /** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */ @@ -151,6 +224,10 @@ export class Realtime { hosted: new Set(), tokens: sendBurst, refilled: Date.now(), + watching: new Set(), + watchGen: 0, + invites: inviteBurst, + invitesRefilled: Date.now(), send, close, }); @@ -158,7 +235,10 @@ export class Realtime { close(id: string) { const s = this.sessions.get(id); - if (s) this.leaveAllHosted(id, s, false); + if (s) { + this.leaveAllHosted(id, s, false); + this.unwatch(id, s); + } this.sessions.delete(id); if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys()); } @@ -243,11 +323,11 @@ export class Realtime { return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size; } - /** A hosted room's participants: each IMVU account once, with the seat it last took. */ - private participants(n: number): Participant[] { - const seats = new Map(); - for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, m.seat); - return [...seats].map(([imvuId, seat]) => ({ imvu_id: imvuId, seat })); + /** A hosted room's participants: each IMVU account once, with the seat it last took and its IMVU name. */ + participants(n: number): Participant[] { + const seats = new Map(); + for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name }); + return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name })); } private toRoom(n: number, message: object, except?: string) { @@ -255,23 +335,50 @@ export class Realtime { } private participantsChanged(n: number, except?: string) { - this.toRoom(n, { type: "room.participants", room: n, participants: this.participants(n) }, except); + const participants = this.participants(n); + this.toRoom(n, { type: "room.participants", room: n, participants }, except); + const occupancy = { type: "room.occupancy", room: n, participants }; + for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy); } - /** Takes a session out of a hosted room; tell: say so (room.closed), when it didn't ask to leave. */ - private leaveHosted(id: string, s: Session, n: number, tell: boolean) { + /** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to + * leave. */ + private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) { s.hosted.delete(n); if (!this.hosted.get(n)?.members.delete(id)) return; - if (tell) s.send({ type: "room.closed", room: n }); + if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) }); this.participantsChanged(n); } + private toAccount(imvuId: number, message: object) { + for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message); + } + + private unwatch(id: string, s: Session) { + for (const n of s.watching) { + const w = this.watchers.get(n); + w?.delete(id); + if (w?.size === 0) this.watchers.delete(n); + } + s.watching.clear(); + } + private leaveAllHosted(id: string, s: Session, tell: boolean) { for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell); } /** A hosted room closed: everyone in it is told, and its state is dropped. */ hostedClosed(n: number) { + const watching = this.watchers.get(n); + if (watching) { + this.watchers.delete(n); + for (const w of watching) { + const s = this.sessions.get(w); + s?.watching.delete(n); + s?.send({ type: "room.occupancy", room: n, participants: [] }); + } + } + for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id); const room = this.hosted.get(n); if (!room) return; this.hosted.delete(n); @@ -293,7 +400,12 @@ export class Realtime { } } - private async joinHosted(id: string, s: Session, n: number): Promise { + /** Whether an account is the room's owner account, or one of its moderators. */ + private static staff(info: HostedRoomInfo, imvuId: number) { + return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) }; + } + + private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise { const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] }); if (!s.imvuId) return refuse("not_linked"); const imvuId = s.imvuId; @@ -301,23 +413,164 @@ export class Realtime { if (!info) return refuse("not_found"); if (info.closed) return refuse("closed"); const adult = await this.deps.adult(s.userId, imvuId); + const name = await this.deps.accountName(imvuId); + const banned = await this.deps.hostedBanned(n, imvuId); // The session may have closed or changed accounts while waiting. if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked"); s.adult = adult; if (info.adult && !s.adult) return refuse("adult"); + if (banned) return refuse("banned"); let room = this.hosted.get(n); if (!room?.members.has(id)) { + // A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder. + let admit: Invite | undefined; + const staff = Realtime.staff(info, imvuId); + if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) { + const inv = typeof invite === "string" ? this.invites.get(invite) : undefined; + if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private"); + admit = inv; + } if (s.hosted.size >= maxHostedPerSession) return refuse("too_many"); const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId)); if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full"); - if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0 })); - room.members.set(id, { imvuId, seat: 0 }); + if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0, admitted: new Set() })); + if (admit) { + this.invites.delete(admit.id); // single use + room.admitted.add(imvuId); + } + room.members.set(id, { imvuId, seat: 0, name }); s.hosted.add(n); this.participantsChanged(n, id); // the newcomer's list comes in room.joined + if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId); } return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence }; } + /** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */ + private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) { + const target = m.imvu_id; + const ban = m.ban === undefined ? false : m.ban; + const minutes = m.minutes === undefined ? 0 : m.minutes; + if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" }; + if (typeof ban !== "boolean") return { type: "error", message: "bad ban" }; + if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes) + return { type: "error", message: "bad minutes" }; + const result = (reason?: keyof typeof actionErrors) => ({ + type: "room.boot_result", + room: n, + imvu_id: target, + ...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }), + }); + if (!s.imvuId) return result("not_linked"); + const actor = s.imvuId; + const info = await this.deps.hostedRoom(n); + if (!info) return result("not_found"); + if (info.closed) return result("closed"); + if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked"); + const me = Realtime.staff(info, actor); + const them = Realtime.staff(info, target); + // Nobody boots the owner; moderators don't boot moderators; nobody boots themselves. + if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor) + return result("forbidden"); + await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes }); + const room = this.hosted.get(n); + room?.admitted.delete(target); + for (const [sid, member] of [...(room?.members ?? [])]) { + const other = this.sessions.get(sid); + if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted"); + } + return result(); + } + + /** Whether a session may send another invitation now (10, then 10 a minute). */ + private allowInvite(s: Session): boolean { + const now = Date.now(); + s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute); + s.invitesRefilled = now; + if (s.invites < 1) return false; + s.invites -= 1; + return true; + } + + /** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */ + private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise { + if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" }; + const result = (reason?: keyof typeof actionErrors, invite?: string) => ({ + type: "room.invite_result", + room: n, + imvu_id: target, + ...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }), + }); + if (!s.imvuId) return result("not_linked"); + const from = s.imvuId; + if (target === from) return { type: "error", message: "bad imvu_id" }; + const info = await this.deps.hostedRoom(n); + if (!info) return result("not_found"); + if (info.closed) return result("closed"); + const banned = await this.deps.hostedBanned(n, target); + if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked"); + const me = Realtime.staff(info, from); + if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id)) + return result(info.privacy === "private" ? "forbidden" : "not_in_room"); + if (banned) return result("banned"); + const targets = [...this.sessions.values()].filter((t) => t.imvuId === target); + if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult"); + if (!this.allowInvite(s)) return result("rate_limited"); + + const now = Date.now(); + for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k); + const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url"); + s.send(result(undefined, inviteId)); + if (targets.length === 0) { + s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true }); + return; + } + this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false }); + const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName }; + for (const t of targets) if (!info.adult || t.adult) t.send(message); + } + + /** room.invite_reply: the invited account answers (the first answer counts). */ + private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined { + const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined; + if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" }; + if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" }; + if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" }; + if (inv.answered) return { type: "error", message: "already answered" }; + inv.answered = true; + inv.accepted = m.accept; + if (!m.accept) this.invites.delete(inv.id); + // eslint-disable-next-line no-control-regex + const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote); + this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) }); + } + + /** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */ + private async watch(id: string, s: Session, rooms: unknown): Promise { + if ( + !Array.isArray(rooms) || + rooms.length > maxWatched || + !rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN) + ) + return { type: "error", message: "bad rooms" }; + const gen = ++s.watchGen; + this.unwatch(id, s); + const ns = [...new Set(rooms as number[])]; + const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n))); + if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came + for (const [i, n] of ns.entries()) { + const info = infos[i]; + if (!info || info.closed || (info.adult && !s.adult)) continue; + const me = Realtime.staff(info, s.imvuId); + if (info.privacy === "private" && !me.owner && !me.moderator) continue; + s.watching.add(n); + let w = this.watchers.get(n); + if (!w) this.watchers.set(n, (w = new Set())); + w.add(id); + s.send({ type: "room.occupancy", room: n, participants: this.participants(n) }); + } + } + /** Whether a session may send another room message now (a bucket of tokens, refilled over time). */ private allowSend(s: Session): boolean { const now = Date.now(); @@ -358,6 +611,12 @@ export class Realtime { seat?: unknown; mount?: unknown; message?: unknown; + invite?: unknown; + ban?: unknown; + minutes?: unknown; + accept?: unknown; + note?: unknown; + rooms?: unknown; }; if (!m || typeof m !== "object") return { type: "error", message: "not a message" }; if (m.type === "session") { @@ -410,11 +669,28 @@ export class Realtime { this.changed([chat]); return; } - if (m.type === "room.join" || m.type === "room.leave" || m.type === "room.seat" || m.type === "room.send") { + if (m.type === "room.watch") return this.watch(id, s, m.rooms); + if (m.type === "room.invite_reply") return this.inviteReply(s, m); + if ( + m.type === "room.join" || + m.type === "room.leave" || + m.type === "room.seat" || + m.type === "room.send" || + m.type === "room.boot" || + m.type === "room.invite" + ) { const n = m.room; if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" }; - if (m.type === "room.join") return this.joinHosted(id, s, n); - if (m.type === "room.leave") return void this.leaveHosted(id, s, n, false); + if (m.type === "room.join") return this.joinHosted(id, s, n, m.invite); + if (m.type === "room.boot") return this.bootHosted(id, s, n, m); + if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id); + if (m.type === "room.leave") { + this.leaveHosted(id, s, n, false); + // Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room. + const room = this.hosted.get(n); + if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId); + return; + } if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message); const seat = m.seat; if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" }; diff --git a/apps/api/test/hosted.test.ts b/apps/api/test/hosted.test.ts index 6a00ac0..52f0d3f 100644 --- a/apps/api/test/hosted.test.ts +++ b/apps/api/test/hosted.test.ts @@ -1,6 +1,6 @@ // T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies. import { afterAll, beforeAll, expect, test } from "bun:test"; -import { openDatabase, type Database } from "@t5de/db"; +import { eq, openDatabase, schema, type Database } from "@t5de/db"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; import { ImvuClient, type ImvuUser } from "../src/imvu"; @@ -95,7 +95,7 @@ async function proxy(p: Person, imvuId: number) { }; const join = (room: number) => ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room); - return { ws, got, send, last, join }; + return { ws, got, send, last, join, ask }; } async function waitFor(ok: () => boolean) { @@ -237,11 +237,13 @@ test("joining, seats and messages in a hosted room", async () => { // Dee and Eli join; each sees the other. const dj = await d.join(n); - expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0 }], sequence: 0 }); + expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 }); const ej = await e.join(n); - expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]); + expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]); await waitFor(() => d.last("room.participants", n)?.participants.length === 2); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2); + expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants); + expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404); // Full at two people; joining again (a reconnect) answers the same. expect((await f.join(n)).reason).toBe("full"); @@ -320,3 +322,335 @@ test("a room made adult sends non-adults out; an unlinked account leaves its roo g.ws.close(); h.ws.close(); }); + +// ---- Phase 2 ---- + +test("moderators boot and ban; owners lift bans", async () => { + const own = await person("ownr", { id: 601 }); + const mod = await person("modr", { id: 602 }); + const usr = await person("usrr", { id: 603 }); + const md2 = await person("mod2", { id: 604 }); + accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only + const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n; + + // Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20. + expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403); + const many = Array.from({ length: 21 }, (_, i) => i + 1); + expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400); + expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe( + "no_such_imvu_account", + ); + const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] }); + expect(set.status).toBe(200); + expect(set.json.room.moderators).toEqual([602, 604, 605]); + expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]); + await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] }); + expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]); + + const o = await proxy(own, 601); + const m = await proxy(mod, 602); + const u = await proxy(usr, 603); + const m2 = await proxy(md2, 604); + for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined"); + const boot = (p: typeof o, imvu_id: number, extra: object = {}) => + p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id); + + // Who may boot whom. + expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" }); + expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator + expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner + expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves + m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 }); + await waitFor(() => m.got.some((x) => x.message === "bad minutes")); + + // A boot: out, but may come back. + expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false }); + await waitFor(() => u.last("room.closed", n)?.reason === "booted"); + await waitFor(() => o.last("room.participants", n)?.participants.length === 3); + expect((await u.join(n)).type).toBe("room.joined"); + + // A ban: out, and refused. + expect((await boot(m, 603, { ban: true })).ban).toBe(true); + await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2); + expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" }); + + // Bans: listed for the owner and moderators; lifted by the owner only. + expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403); + const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`); + expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]); + expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403); + expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204); + expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404); + expect((await u.join(n)).type).toBe("room.joined"); + + // A ban for a while; the owner may boot a moderator. + await boot(o, 603, { ban: true, minutes: 60 }); + const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until; + expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000); + expect((await boot(o, 604)).ok).toBe(true); + await waitFor(() => m2.last("room.closed", n)?.reason === "booted"); + + // Logged. + const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n))); + const actions = log.map((e) => e.action); + expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2); + expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2); + expect(actions).toContain("hosted_room.unban"); + expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 }); + for (const p of [o, m, u, m2]) p.ws.close(); +}); + +test("private rooms and invitations", async () => { + const pam = await person("pamm", { id: 701 }); + const quin = await person("quin", { id: 702 }); + const rex = await person("rexx", { id: 703 }, { id: 705 }); + const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n; + expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private"); + await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] }); + const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n; + + // Listed only for the owner and moderators. + const listed = async (p?: Person, q = "") => + ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n); + expect(await listed()).toBe(false); + expect(await listed(rex)).toBe(false); + expect(await listed(pam)).toBe(true); + expect(await listed(quin)).toBe(true); + expect(await listed(pam, "?mine=1")).toBe(true); + expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there + + const p = await proxy(pam, 701); + const q = await proxy(quin, 702); + const r = await proxy(rex, 703); + expect((await r.join(n)).reason).toBe("private"); + expect((await q.join(n)).type).toBe("room.joined"); + const invite = (from: typeof p, room: number, imvu_id: number) => + from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id); + const joinWith = (x: typeof p, invite: string) => + x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error")); + + // Only the owner and moderators invite to a private room (anyone in it, to a public one). + expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" }); + expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" }); + + // Invited: the invitee's sessions are told; it answers; the inviter hears. + const sent = await invite(p, n, 703); + expect(sent.ok).toBe(true); + const id: string = sent.invite; + await waitFor(() => r.last("room.invited", n) !== undefined); + expect(r.last("room.invited", n)).toEqual({ + type: "room.invited", + room: n, + invite: id, + from: 701, + room_name: "Hideout", + owner_name: "pammAv", + }); + expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet + q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer + await waitFor(() => q.got.some((x) => x.message === "no such invite")); + r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" }); + await waitFor(() => p.last("room.invite_answer") !== undefined); + expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" }); + r.send({ type: "room.invite_reply", invite: id, accept: false }); + await waitFor(() => r.got.some((x) => x.message === "already answered")); + + // An accepted invite lets the account in once; reconnecting is fine, leaving ends it. + expect((await joinWith(r, id)).type).toBe("room.joined"); + r.ws.close(); + const r2 = await proxy(rex, 703); + expect((await r2.join(n)).type).toBe("room.joined"); + r2.send({ type: "room.leave", room: n }); + await waitFor(() => q.last("room.participants", n)?.participants.length === 1); + expect((await joinWith(r2, id)).reason).toBe("private"); + + // Declined. + const second = (await invite(q, n, 703)).invite; + await waitFor(() => r2.got.some((x) => x.invite === second)); + r2.send({ type: "room.invite_reply", invite: second, accept: false }); + await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second)); + expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false); + expect((await joinWith(r2, second)).reason).toBe("private"); + + // Nobody online as that account; banned accounts aren't invited. + const off = await invite(p, n, 705); + expect(off.ok).toBe(true); + await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite)); + expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({ + type: "room.invite_answer", + invite: off.invite, + to: 705, + accept: false, + offline: true, + }); + await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result"); + expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" }); + + // Public rooms: anyone in it invites. + expect((await r2.join(pub)).type).toBe("room.joined"); + expect((await invite(r2, pub, 701)).ok).toBe(true); + await waitFor(() => p.last("room.invited", pub) !== undefined); + for (const x of [p, q, r2]) x.ws.close(); +}); + +const png = (bytes = 64) => { + const b = new Uint8Array(bytes); + b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + return b; +}; + +async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") { + const form = new FormData(); + form.set("image", new File([data as Uint8Array], "room.png", { type })); + const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { + method: "PUT", + headers: { Origin: webURL, Cookie: p.cookie }, + body: form, + }); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : undefined }; +} + +test("room images", async () => { + const ivy = await person("ivyy", { id: 801 }); + const jon = await person("jonn", { id: 802 }); + const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n; + expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull(); + expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404); + + expect((await upload(jon, n, png())).status).toBe(403); + const gif = new TextEncoder().encode("GIF89a................................"); + expect((await upload(ivy, n, gif)).json.error).toBe("invalid"); + const svg = new TextEncoder().encode(''); + expect((await upload(ivy, n, svg)).status).toBe(400); + expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400); + const up = await upload(ivy, n, png()); + expect(up.status).toBe(200); + expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`)); + + const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`); + expect(got.status).toBe(200); + expect(got.headers.get("content-type")).toBe("image/png"); + expect(got.headers.get("cache-control")).toBe("public, max-age=3600"); + expect(got.headers.get("x-content-type-options")).toBe("nosniff"); + expect(new Uint8Array(await got.arrayBuffer())).toEqual(png()); + const etag = got.headers.get("etag")!; + expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304); + + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]); + expect((await upload(ivy, n, jpeg)).status).toBe(200); + const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`); + expect(again.headers.get("content-type")).toBe("image/jpeg"); + expect(again.headers.get("etag")).not.toBe(etag); + + expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403); + expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull(); + expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404); +}); + +test("favorites, ratings and recent rooms", async () => { + const kim = await person("kimm", { id: 901 }); + const lou = await person("louu", { id: 902 }); + const mia = await person("miaa", { id: 903 }); + const ns: number[] = []; + for (const name of ["One", "Two", "Three"]) + ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n); + const [a, b, c] = ns as [number, number, number]; + + // Favorites. + expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401); + expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401); + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204); + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204); + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204); + expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404); + const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n); + expect(await favs(lou)).toEqual([c, a]); + expect(await favs(mia)).toEqual([]); + const flag = async (p: Person | undefined, n: number) => + ((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite; + expect(await flag(lou, a)).toBe(true); + expect(await flag(mia, a)).toBe(false); + expect(await flag(undefined, a)).toBe(false); + expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true); + expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204); + expect(await favs(lou)).toEqual([c]); + + // Ratings. + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400); + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400); + expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3); + expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 }); + await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 }); + const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`); + expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 }); + expect(one.json.myRating).toBe(4); + expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0); + expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 }); + expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204); + expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 }); + + // Recent rooms: joined, latest first, each once. + expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401); + const l = await proxy(lou, 902); + const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n); + for (const n of [a, b, c]) { + await l.join(n); + await Bun.sleep(20); + } + for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10); + expect(await recent()).toEqual([c, b, a]); + l.send({ type: "room.leave", room: a }); + await Bun.sleep(30); + await l.join(a); + for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10); + expect(await recent()).toEqual([a, c, b]); + const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a)); + expect(visit.map((v) => v.imvuId)).toEqual([902]); + l.ws.close(); +}); + +test("watching rooms' occupancy", async () => { + const ned = await person("nedd", { id: 1001 }); + const ola = await person("olaa", { id: 1002 }); + const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n; + const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n; + const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n; + + const w = await proxy(ola, 1002); + const n = await proxy(ned, 1001); + w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) }); + await waitFor(() => w.got.some((x) => x.message === "bad rooms")); + w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] }); + await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined); + expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] }); + // The private room's owner may watch it. + n.send({ type: "room.watch", rooms: [hidden] }); + await waitFor(() => n.last("room.occupancy", hidden) !== undefined); + + await n.join(a); + await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1); + expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]); + n.send({ type: "room.seat", room: a, seat: 2 }); + await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2); + n.send({ type: "room.leave", room: a }); + await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0); + await n.join(hidden); + await Bun.sleep(50); + expect(w.last("room.occupancy", hidden)).toBeUndefined(); + expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]); + + // Replaced; closing tells watchers it's empty. + w.send({ type: "room.watch", rooms: [b] }); + await Bun.sleep(50); + const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length; + await n.join(a); + await n.join(b); + await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1); + expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before); + await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`); + await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0); + w.ws.close(); + n.ws.close(); +}); diff --git a/apps/web/app/rooms/[n]/edit/page.tsx b/apps/web/app/rooms/[n]/edit/page.tsx index 4ac53b4..19072ae 100644 --- a/apps/web/app/rooms/[n]/edit/page.tsx +++ b/apps/web/app/rooms/[n]/edit/page.tsx @@ -6,7 +6,9 @@ import { PageHeader } from "@/components/status"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { FieldGroup } from "@/components/ui/field"; import { api, getMe, unwrap } from "@/lib/api"; -import { closeRoom, saveRoom } from "../../actions"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { closeRoom, removeImage, saveImage, saveRoom } from "../../actions"; import { RoomFields } from "../../room-fields"; export const metadata: Metadata = { title: "Edit room" }; @@ -40,7 +42,7 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string - l.adult)} /> + l.adult)} moderators />
Save
@@ -49,6 +51,36 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string
+ + + Image + Shown in room lists. + + + {room.image && ( + // The room's image, from the hub. + + )} + + + {room.image ? "Replace" : "Upload"} + + PNG or JPEG, up to 512 KB. + +
+ Save image +
+
+ {room.image && ( + + + Remove image + + + )} +
+
+ Close room diff --git a/apps/web/app/rooms/actions.ts b/apps/web/app/rooms/actions.ts index 7c4b468..5222a50 100644 --- a/apps/web/app/rooms/actions.ts +++ b/apps/web/app/rooms/actions.ts @@ -5,6 +5,12 @@ import { api, unwrap } from "@/lib/api"; import { attempt, field, type ActionState } from "@/lib/actions"; const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const)); +const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const)); +const moderators = (form: FormData) => + field(form, "moderators") + .split(/[,\n]/) + .map((s) => s.trim()) + .filter(Boolean); /** Makes a hosted room, then goes back to the list. */ export async function createRoom(_: ActionState, form: FormData): Promise { @@ -17,6 +23,7 @@ export async function createRoom(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + const image = form.get("image"); + if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." }; + if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." }; + return attempt(async () => { + unwrap(await api["hosted-rooms"]({ n }).image.put({ image })); + revalidatePath("/rooms"); + revalidatePath(`/rooms/${n}/edit`); + return "Image saved."; + }); +} + +export async function removeImage(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + return attempt(async () => { + unwrap(await api["hosted-rooms"]({ n }).image.delete()); + revalidatePath("/rooms"); + revalidatePath(`/rooms/${n}/edit`); + }); +} + +/** Stars a room, or (favorite=0) unstars it. */ +export async function setFavorite(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + return attempt(async () => { + const room = api["hosted-rooms"]({ n }).favorite; + unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete())); + revalidatePath("/rooms"); + }); +} + +export async function rateRoom(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + return attempt(async () => { + unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) })); + revalidatePath("/rooms"); + }); +} + /** Closes a room: everyone in it is sent out. */ export async function closeRoom(_: ActionState, form: FormData): Promise { const n = field(form, "n"); diff --git a/apps/web/app/rooms/page.tsx b/apps/web/app/rooms/page.tsx index 8702add..a71d421 100644 --- a/apps/web/app/rooms/page.tsx +++ b/apps/web/app/rooms/page.tsx @@ -4,6 +4,7 @@ import { PageHeader, Tag } from "@/components/status"; import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { api, getMe, unwrap, type HostedRoom } from "@/lib/api"; +import { RoomActions } from "./room-actions"; export const metadata: Metadata = { title: "Rooms" }; @@ -39,7 +40,7 @@ export default async function Rooms() {
    {hosted.map((r) => (
  • - +
  • ))}
@@ -93,13 +94,18 @@ export default async function Rooms() { ); } -function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) { +function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) { return ( - - + + {r.image && ( + // The room's image, from the hub. + + )} +
{r.name} {r.rating === "adult" && Adult} + {r.privacy === "private" && Private} {r.occupancy > 0 && ( {r.occupancy} here @@ -113,8 +119,10 @@ function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {

{r.ownerName} · {r.occupancy}/{r.maxUsers} + {r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}

{r.description &&

{r.description}

} + {signedIn && }
); diff --git a/apps/web/app/rooms/room-actions.tsx b/apps/web/app/rooms/room-actions.tsx new file mode 100644 index 0000000..dc9b6cf --- /dev/null +++ b/apps/web/app/rooms/room-actions.tsx @@ -0,0 +1,28 @@ +"use client"; +// A hosted room card's favorite star and rating buttons. +import { Star } from "lucide-react"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { cn } from "@/lib/utils"; +import { rateRoom, setFavorite } from "./actions"; + +export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) { + return ( +
+ + + + + + {canRate && ( + + Rate + {[1, 2, 3, 4, 5].map((k) => ( + + {k} + + ))} + + )} +
+ ); +} diff --git a/apps/web/app/rooms/room-fields.tsx b/apps/web/app/rooms/room-fields.tsx index 0c4da6a..8f7199c 100644 --- a/apps/web/app/rooms/room-fields.tsx +++ b/apps/web/app/rooms/room-fields.tsx @@ -5,7 +5,10 @@ import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select" import { Textarea } from "@/components/ui/textarea"; import type { HostedRoom } from "@/lib/api"; -export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: boolean }) { +/** The moderators as the edit field shows them: names where known, else IDs. */ +const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", "); + +export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) { return ( <> @@ -55,6 +58,21 @@ export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: bo /> + + Privacy + + Public + Private + + Private: unlisted; only you, moderators and people invited get in. + + {moderators && room && ( + + Moderators + + Up to 20, comma-separated. They can boot and ban. + + )} ); } diff --git a/docs/api.md b/docs/api.md index 831f49e..21c00ad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,14 +57,16 @@ the `GET`s also signed out. | | | | --- | --- | -| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. | -| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200); adult ones for adults only. `?mine=1`: the person's own. | -| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}}`; 404, or 410 `closed` | -| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?}` → `{room}`. Made adult: non-adults in it are sent out. | +| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. | +| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. | +| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` | +| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. | | `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. | | `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` | +| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" | A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy, +privacy, moderators, moderatorNames, image, favorite, score, ratings, createdAt}`; `instanceId` is `-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now. ## Decorations diff --git a/docs/hosted-rooms.md b/docs/hosted-rooms.md index faeadc7..f3b12ab 100644 --- a/docs/hosted-rooms.md +++ b/docs/hosted-rooms.md @@ -6,7 +6,8 @@ room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what hears of hosted rooms. Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the -owner's furniture), occupancy in the room list. Later: moderators, bans and boots, invitations, private rooms. +owner's furniture), occupancy in the room list. Phase 2 (below): moderators, boots and bans, private rooms, +invitations, images, favorites, ratings, recent rooms, occupancy pushed to watchers, participant names. ## IDs @@ -81,3 +82,116 @@ stored (design.md: chat retention). `node` mount, and nothing more. - **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/-<2100000000+n>` queues are answered from `room.participants`. + +## Phase 2 + +Moderators, boots and bans, private rooms, invitations, images, favorites, ratings, recent rooms, occupancy pushed +to watchers, and participants' names. Everything in phase 1 still holds; this adds to it. + +### Rooms (REST) + +A room (in every answer that has one) also has: + +| Field | | +| --- | --- | +| `privacy` | `"public"` (default) or `"private"` | +| `moderators` | IMVU account IDs, up to 20, in the order the owner gave them (never the owner's own account) | +| `moderatorNames` | their IMVU names, same order: the name of the linked account, or as IMVU gave it when added by name; `""` when not known | +| `image` | `/api/v1/hosted-rooms//image?v=<16 hex>` (absolute; `v` changes with the image), or `null` | +| `favorite` | whether it's one of the signed-in person's favorites (`false` signed out) | +| `score` | the average rating, 1-5, two decimals; `0` if nobody rated it. (Named `score`, not `rating`: `rating` is already the room's `general`/`adult`.) | +| `ratings` | how many people rated it | + +Who's who: the room's **owner** is, over REST, the person who made it; over realtime, its owner IMVU account +(`ownerImvuId`). A **moderator** is an IMVU account in `moderators`; over REST, a person is a moderator if one of their +linked accounts is. + +| Route | | +| --- | --- | +| `POST /api/v1/hosted-rooms` | also takes `privacy?` | +| `PATCH /api/v1/hosted-rooms/:n` | (owner) also takes `privacy?` and `moderators?`: up to 20 entries, each an IMVU ID (integer, or a string of digits) or an IMVU name (looked up: linked accounts first, then IMVU, rate-limited; unknown: 404 `no_such_imvu_account`). Replaces the list; duplicates and the owner's account are dropped. More than 20: 400 `invalid`. Making a room private sends no one out. | +| `GET /api/v1/hosted-rooms` | public rooms, and private ones the person owns or moderates. `?mine=1`: the person's own (private too). `?favorites=1`: their favorites, latest starred first. `?recent=1`: the last 20 distinct rooms they joined (any of their accounts), latest first. Those three need a signed-in person (401 `signed_out`); adult rooms only for adults, as before; closed and hidden rooms left out. | +| `GET /api/v1/hosted-rooms/:n` | anyone may get a private room (it's unlisted, not secret). Also `myRating`: the person's own rating, `0` if none or signed out. | +| `GET /api/v1/hosted-rooms/:n/participants` | (anyone who may `GET /:n`, also signed out; same 404/410) `{participants: [{imvu_id, seat, name}]}`, as in `room.participants` | +| `GET /api/v1/hosted-rooms/:n/bans` | (owner or moderator) `{bans: [{imvuId, byImvuId, until, createdAt}]}`: bans in force, latest first; `until` ISO time or `null` (for good). Others: 403 `forbidden`. | +| `DELETE /api/v1/hosted-rooms/:n/bans/:imvu` | (owner only) lifts a ban: 204; 404 if there's none. Logged (`hosted_room.unban`). | +| `PUT /api/v1/hosted-rooms/:n/image` | (owner) multipart, field `image`: a PNG or JPEG (by its magic bytes, whatever the declared type), up to 512 KB; else 400 `invalid`. Stored as uploaded. → `{room}`. 30 an hour. | +| `DELETE /api/v1/hosted-rooms/:n/image` | (owner) → `{room}` with `image: null` | +| `GET /api/v1/hosted-rooms/:n/image` | (anyone who may `GET /:n`) the image, with its `Content-Type`, `ETag` (`If-None-Match` answers 304), `Cache-Control: public, max-age=3600` (`private` for adult rooms), `X-Content-Type-Options: nosniff`; 404 if none | +| `PUT /api/v1/hosted-rooms/:n/favorite` | (signed in, a room they may see) stars it: 204, also if starred already | +| `DELETE /api/v1/hosted-rooms/:n/favorite` | 204 | +| `PUT /api/v1/hosted-rooms/:n/rating` | `{score}` (integer 1-5) → `{room, myRating}`; replaces their earlier rating. Owners can't rate their own room (403 `forbidden`). | +| `DELETE /api/v1/hosted-rooms/:n/rating` | removes their rating: 204 | + +A visit is recorded when an IMVU account joins a hosted room (a new member; not a re-join of a session already in +it): per person, the room, the account and the time; the latest 50 per person are kept. + +### Realtime + +**Participants** everywhere (`room.joined`, `room.participants`, `room.occupancy`, `GET .../participants`) are +`{"imvu_id": X, "seat": k, "name": "..."}`; `name` is the IMVU username of the hub's verified link for that account +(as of when it joined), `""` if unknown. + +**Joining** (`room.join`) may carry `"invite": id`. New `room.error` reasons, checked in this order after phase 1's +`not_linked`, `not_found`, `closed`, `adult`: `"banned"` (a ban in force on the session's account), then `"private"` +(a private room, and the account isn't its owner account, a moderator, already let in, or holding an accepted, +unexpired invite for this room and account), then `too_many`, `full`. + +An invite used to join is spent, and the account is **let in**: it may join again without one (a reconnect, another +session) until it sends `room.leave` and none of its sessions is still in the room, or it's booted. (A connection +dropping doesn't end it. It's kept in memory: a hub restart ends it.) + +**Boot and ban**, from the owner account or a moderator (needn't be in the room): + +- proxy → hub `{"type": "room.boot", "room": n, "imvu_id": X, "ban"?: false, "minutes"?: 0}`. `ban` true stores a + ban; `minutes` 0 (default) is for good, else 1 to 5,270,400 (about ten years). Malformed: `{"type": "error", + "message": "bad imvu_id" | "bad ban" | "bad minutes" | "bad room"}`. +- hub → proxy (the sender) `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": true, "ban": bool}`, or + `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": false, "reason": "...", "message": "..."}` with + `reason` `"not_linked"`, `"not_found"`, `"closed"` or `"forbidden"`: the sender isn't the owner account or a + moderator; X is the owner account (nobody boots the owner); X is a moderator and the sender isn't the owner + account; or X is the sender. +- Every session of X in the room gets `{"type": "room.closed", "room": n, "reason": "booted"}` and is out; the others + get `room.participants`; watchers `room.occupancy`. A boot without a ban doesn't stop X coming back (except into + a private room, which needs a new invite). +- Logged: `hosted_room.boot` or `hosted_room.ban`, target the room number, detail `{imvu: X, by: sender's IMVU ID}` + (and `minutes` for a ban), actor the sender's person. A banned moderator stays a moderator (the owner removes them + from `moderators`). + +**Invitations**: + +- proxy → hub `{"type": "room.invite", "room": n, "imvu_id": X}`: from a session in the room (public rooms), or from + the owner account or a moderator, in it or not (private rooms). 10, then 10 a minute per session. +- hub → proxy (the sender) `{"type": "room.invite_result", "room": n, "imvu_id": X, "ok": true, "invite": id}`, or + `ok: false` with `reason` `"not_linked"`, `"not_found"`, `"closed"`, `"forbidden"` (private room, not owner or + moderator), `"not_in_room"` (public room, the sender isn't in it), `"banned"` (X is banned from the room), + `"adult"` (an adult room, and none of X's sessions counts as adult) or `"rate_limited"`, and `message`. X being the + sender: `{"type": "error", "message": "bad imvu_id"}`. +- When `ok`: every session whose IMVU account is X gets `{"type": "room.invited", "room": n, "invite": id, "from": + sender's IMVU ID, "room_name": "...", "owner_name": "..."}` (`owner_name`: the owner account's IMVU name). If X has + no session, the sender instead gets `{"type": "room.invite_answer", "invite": id, "to": X, "accept": false, + "offline": true}` right after the result, and the invite is dropped. +- proxy (X's) → hub `{"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."}`. The first answer + counts. `note`: control characters become spaces, trimmed, up to 200 characters, left out when empty. Errors: + `{"type": "error", "message": "no such invite" | "already answered" | "bad accept" | "bad note"}` (`no such invite` + also when it expired or the session's account isn't X). +- hub → proxy (every session of the sender's IMVU account) `{"type": "room.invite_answer", "invite": id, "to": X, + "accept": bool, "note"?: "..."}`. +- An invite: single use, valid 10 minutes from when it was made (accepting doesn't extend it), for that room and + account. Kept in memory only; closing the room drops its invites. `invite` ids are opaque strings (base64url). + +**Occupancy**: + +- proxy → hub `{"type": "room.watch", "rooms": [n, ...]}`: replaces the session's watched set (up to 100; `[]` + stops). Not a list of room numbers, or over 100: `{"type": "error", "message": "bad rooms"}`. Rooms the session + may not see are left out silently: no such room, closed or hidden; adult rooms for a session whose account isn't + adult; private rooms unless its account is the owner account or a moderator. That's checked when it watches. +- hub → proxy, once per watched room right after, then whenever the room's participants change (join, leave, seat, + boot): `{"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}`. A room closing sends + `participants: []` and stops watching it. A session closing stops its watching. + +### The proxy's part (phase 2) + +- Room cards and lists: `privacy`, `image`, `score`/`ratings` from the room views; people in a room by name from + `participants` (`name`), or `GET .../participants` for a room it isn't in or watching. +- `room_occupancy/-<2100000000+n>` queues: from `room.watch`/`room.occupancy` instead of polling. diff --git a/packages/db/drizzle/0007_hosted_rooms_phase2.sql b/packages/db/drizzle/0007_hosted_rooms_phase2.sql new file mode 100644 index 0000000..e236219 --- /dev/null +++ b/packages/db/drizzle/0007_hosted_rooms_phase2.sql @@ -0,0 +1,53 @@ +CREATE TABLE "hosted_room_ban" ( + "n" integer NOT NULL, + "imvu_id" bigint NOT NULL, + "by_imvu_id" bigint NOT NULL, + "until" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id") +); +--> statement-breakpoint +CREATE TABLE "hosted_room_favorite" ( + "user_id" text NOT NULL, + "n" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n") +); +--> statement-breakpoint +CREATE TABLE "hosted_room_image" ( + "n" integer PRIMARY KEY NOT NULL, + "content_type" text NOT NULL, + "data" "bytea" NOT NULL, + "hash" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "hosted_room_rating" ( + "user_id" text NOT NULL, + "n" integer NOT NULL, + "score" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n") +); +--> statement-breakpoint +CREATE TABLE "hosted_room_visit" ( + "user_id" text NOT NULL, + "n" integer NOT NULL, + "imvu_id" bigint NOT NULL, + "at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n") +); +--> statement-breakpoint +ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint +ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint +CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0007_snapshot.json b/packages/db/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..66da437 --- /dev/null +++ b/packages/db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2244 @@ +{ + "id": "8b28a842-b783-4b8e-b198-96a2a1c18ec8", + "prevId": "2854e817-9ec7-4e0b-9d38-f68a9b439aac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room_decoration": { + "name": "room_decoration", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ae508f5..1b33ead 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1793357692630, "tag": "0006_decorations", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1793364727935, + "tag": "0007_hosted_rooms_phase2", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b95206d..b60e38c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import * as schema from "./schema"; export { schema }; -export type { DecorationItem, FileEntry, ParentInfo, SceneItem } from "./schema"; +export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema"; export * from "drizzle-orm"; const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4d1c685..37d399f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -5,6 +5,7 @@ import { sql } from "drizzle-orm"; import { bigint, boolean, + customType, index, integer, jsonb, @@ -356,12 +357,99 @@ export const hostedRoom = pgTable( scene: jsonb("scene").$type().notNull().default([]), sceneHash: text("scene_hash").notNull().default(""), closed: boolean("closed").notNull().default(false), + // Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees). + privacy: text("privacy").notNull().default("public"), + // Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then. + moderators: jsonb("moderators").$type().notNull().default([]), + // The room's image (hosted_room_image), by the sha256 of its bytes; null: none. + imageHash: text("image_hash"), createdAt: createdAt(), updatedAt: updatedAt(), }, (t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)], ); +/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */ +export type RoomModerator = { imvuId: number; username: string }; + +const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({ + dataType: () => "bytea", + fromDriver: (v) => Buffer.from(v), +}); + +// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB). +export const hostedRoomImage = pgTable("hosted_room_image", { + n: integer("n") + .primaryKey() + .references(() => hostedRoom.n, { onDelete: "cascade" }), + contentType: text("content_type").notNull(), // "image/png", "image/jpeg" + data: bytea("data").notNull(), + hash: text("hash").notNull(), // sha256, hex + updatedAt: updatedAt(), +}); + +// IMVU accounts banned from a hosted room; until null: for good. +export const hostedRoomBan = pgTable( + "hosted_room_ban", + { + n: integer("n") + .notNull() + .references(() => hostedRoom.n, { onDelete: "cascade" }), + imvuId: bigint("imvu_id", { mode: "number" }).notNull(), + byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(), + until: timestamp("until", { withTimezone: true }), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.n, t.imvuId] })], +); + +// A person's favorite hosted rooms. +export const hostedRoomFavorite = pgTable( + "hosted_room_favorite", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + n: integer("n") + .notNull() + .references(() => hostedRoom.n, { onDelete: "cascade" }), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.userId, t.n] })], +); + +// A person's rating of a hosted room, 1-5. +export const hostedRoomRating = pgTable( + "hosted_room_rating", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + n: integer("n") + .notNull() + .references(() => hostedRoom.n, { onDelete: "cascade" }), + score: integer("score").notNull(), + updatedAt: updatedAt(), + }, + (t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)], +); + +// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept. +export const hostedRoomVisit = pgTable( + "hosted_room_visit", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + n: integer("n") + .notNull() + .references(() => hostedRoom.n, { onDelete: "cascade" }), + imvuId: bigint("imvu_id", { mode: "number" }).notNull(), + at: timestamp("at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)], +); + // ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ---- /** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */