Files
t5de-hub/apps/api/src/hosted.ts
T
dhkatzandClaude Opus 5.5 76c8e0342e
CI / test (push) Successful in 1m10s
Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:07:41 -07:00

438 lines
17 KiB
TypeScript

// 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");
// "<owner's IMVU ID>-<hostedBase + n>": 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<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers" | "privacy">> & {
/** 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<number>) => {
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<Row[]> {
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<Row> {
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<HostedRoomInfo | undefined> {
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<number>([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<boolean> {
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<boolean> {
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<Set<number>> {
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<number> {
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<Map<number, { score: number; ratings: number }>> {
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,
};
}