T5 products placed in ordinary IMVU rooms, kept per room instance
(room_decoration, migration 0006). GET /api/v1/decorations/:instance (and
?instances= for up to 20) gives what the viewer may see, with a hash;
PUT is for the room owner's T5DE person only, checks the products exist,
and tells every realtime session with a chat in that room ({"type":
"decor"}). Hosted rooms' IDs are refused.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2a1fc94df6
commit
f65f36c1ea
12 files changed
+2325
-6
No files matched your search
@@ -0,0 +1,107 @@
|
||||
// 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, 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: "<owner's customer ID>-<n>". */
|
||||
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 (owner >= 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<Set<string>> {
|
||||
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),
|
||||
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<DecorationView[]> {
|
||||
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<void> {
|
||||
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)));
|
||||
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() } });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user