// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it, // kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the // room is realtime.ts's decorated(). import { createHash } from "node:crypto"; import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db"; import { hostedBase } from "./realtime"; const { roomDecoration, product, productRevision, imvuLink } = schema; export const maxDecorations = 300; export const maxDecorationPlacement = 200; export const maxBatch = 20; /** An IMVU room instance: "-". */ export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/; /** An error for the person; the routes turn it into a Problem. */ export class DecorationError extends Error { constructor( readonly status: number, readonly slug: string, message: string, ) { super(message); } } export const decorationHash = (contents: DecorationItem[]) => createHash("sha256").update(JSON.stringify(contents)).digest("hex"); /** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */ function ownerOf(instance: string): number { if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room."); const owner = Number(instance.slice(0, instance.indexOf("-"))); if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase) throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene."); return owner; } export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string }; export class Decorations { constructor(private readonly db: Database) {} /** * Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't * retracted, and general unless the viewer counts as adult. */ private async visible(ids: string[], adult: boolean): Promise> { if (ids.length === 0) return new Set(); const rows = await this.db .select({ id: product.id }) .from(product) .where( and( inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt), adult ? undefined : eq(product.rating, "general"), sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`, ), ); return new Set(rows.map((r) => r.id)); } /** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */ async get(instances: string[], adult: boolean): Promise { for (const i of instances) ownerOf(i); const rows = instances.length ? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances)) : []; const stored = new Map(rows.map((r) => [r.instanceId, r.contents])); const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult); return instances.map((instance) => { const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1])); return { instance, contents, hash: decorationHash(contents) }; }); } /** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */ async set(personId: string, instance: string, contents: DecorationItem[]): Promise { const owner = ownerOf(instance); const [link] = await this.db .select() .from(imvuLink) .where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId))); if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it."); if (contents.length > maxDecorations) throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`); if (new Set(contents.map((it) => it[0])).size !== contents.length) throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once."); const ids = [...new Set(contents.map((it) => it[1]))]; if (ids.length) { const known = await this.db .select({ id: product.id }) .from(product) .where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt))); if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist."); } if (contents.length === 0) { await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance)); return; } await this.db .insert(roomDecoration) .values({ instanceId: instance, ownerImvuId: owner, contents }) .onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } }); } }