CI / test (push) Successful in 1m10s
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]>
438 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|