// 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, 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, 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, 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 { constructor( readonly status: number, readonly slug: string, message: string, ) { super(message); } } 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[]) => createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex"); // "-": the client takes a room's owner from the first part (so the owner can // decorate it), and IMVU's own room numbers are far below hostedBase. export const instanceId = (owner: number, n: number) => `${owner}-${hostedBase + n}`; export const chatId = (n: number) => hostedBase + n; // eslint-disable-next-line no-control-regex const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g; function cleanName(s: string): string { const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim(); if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters."); return v; } function cleanDescription(s: string): string { const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim(); if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters."); 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; roomPid: number; rating: Rating; maxUsers?: number; imvuId: number; privacy?: Privacy; }; 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) {} // Rooms that are shown: the owner account still linked to the owner, the owner not banned. private shown() { return this.db .select({ room: hostedRoom, ownerName: imvuLink.username }) .from(hostedRoom) .innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId))) .innerJoin(user, eq(user.id, hostedRoom.ownerId)) .$dynamic(); } private visible = sql`coalesce(${user.banned}, false) = false`; /** 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); } /** 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): 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."); if (r.room.closed) throw new HostedError(410, "closed", "This room is closed."); if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId) throw new HostedError(404, "not_found", "No such room."); return r; } /** 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))); return r ? roomInfo(r) : undefined; } async create(ownerId: string, adultPerson: boolean, body: NewRoom) { const name = cleanName(body.name); const description = cleanDescription(body.description ?? ""); if (body.rating === "adult" && !adultPerson) throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account."); const [link] = await this.db .select() .from(imvuLink) .where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId))); if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you."); return this.db.transaction(async (tx) => { const [{ n } = { n: 0 }] = await tx .select({ n: count() }) .from(hostedRoom) .where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false))); if (n >= maxRoomsPerPerson) throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`); const [r] = await tx .insert(hostedRoom) .values({ ownerId, ownerImvuId: link.imvuId, name, description, roomPid: body.roomPid, rating: body.rating, maxUsers: body.maxUsers ?? 10, privacy: body.privacy ?? "public", scene: [], sceneHash: sceneHash(body.roomPid, []), }) .returning(); return { room: r!, ownerName: link.username }; }); } /** The owner's room, open (for changes). */ 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; } async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) { const { room: r, ownerName } = await this.own(ownerId, n); 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({ ...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}), ...(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)) .returning(); return { room: updated!, ownerName }; } async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) { const { room: r, ownerName } = await this.own(ownerId, n); const pid = roomPid ?? r.roomPid; const [updated] = await this.db .update(hostedRoom) .set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) }) .where(eq(hostedRoom.n, n)) .returning(); return { room: updated!, ownerName }; } /** Closes a room: by its owner, or (moderator) anyone's. */ async close(actorId: string, moderator: boolean, n: number) { const { room: r } = await this.get(n, true, actorId); if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this."); 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, }; }