Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:07:41 -07:00
1 parent 43183ec7ee
commit 76c8e0342e
16 files changed
+3851 -73

No files matched your search

+294 -14
View File
@@ -2,7 +2,21 @@
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
// token.
import { Elysia, t } from "elysia";
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db";
import {
and,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
count,
schema,
type Database,
type ParentInfo,
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
@@ -11,7 +25,19 @@ import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
import {
chatId,
HostedError,
HostedRooms,
instanceId,
maxImageBytes,
maxModerators,
maxPlacement,
maxSceneItems,
roomInfo,
type RoomChanges,
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -82,6 +108,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const decorations = new Decorations(db);
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
const realtime =
rt ??
new Realtime({
@@ -94,6 +121,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
hostedRoom: (n) => hosted.info(n),
hostedBanned: (n, imvuId) => hosted.banned(n, imvuId),
hostedBoot: async (b) => {
if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes);
await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), {
imvu: b.imvuId,
by: b.by,
...(b.ban ? { minutes: b.minutes } : {}),
});
},
accountName: async (imvuId) => {
const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.username ?? "";
},
hostedVisit: (userId, n, imvuId) => {
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
},
});
const limiter = new Limiter();
@@ -225,7 +268,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}
};
const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({
type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string };
type Extras = { favorite: boolean; score: number; ratings: number };
const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({
n: r.n,
instanceId: instanceId(r.ownerImvuId, r.n),
chatId: chatId(r.n),
@@ -237,11 +283,77 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
ownerImvuId: r.ownerImvuId,
ownerName,
occupancy: realtime.occupancy(r.n),
privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private",
moderators: r.moderators.map((m) => m.imvuId),
moderatorNames: r.moderators.map((m) => m.username),
image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null,
favorite: extras.favorite,
score: extras.score,
ratings: extras.ratings,
createdAt: r.createdAt.toISOString(),
});
/** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */
const hostedViews = async (rows: HostedRow[], person: Person | undefined) => {
const ns = rows.map((r) => r.room.n);
const [scores, favorites] = await Promise.all([
hosted.scores(ns),
person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set<number>()),
]);
return rows.map((r) =>
hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }),
);
};
const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!;
/** The person and their linked IMVU accounts. */
const viewerOf = async (p: Person): Promise<Viewer> => ({
id: p.id,
imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id),
});
/** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */
const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => {
if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`);
const out: RoomModerator[] = [];
for (const e of entries) {
const text = typeof e === "string" ? e.trim() : "";
const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0;
if (id > 0) {
const name = known.find((m) => m.imvuId === id)?.username;
const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id));
out.push({ imvuId: id, username: name ?? l?.username ?? "" });
continue;
}
if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs.");
const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase());
if (k) {
out.push(k);
continue;
}
const [l] = await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username })
.from(imvuLink)
.where(sql`lower(${imvuLink.username}) = lower(${text})`);
if (l) {
out.push(l);
continue;
}
try {
const found = await imvuCall(me, () => imvu.byUsername(text));
out.push({ imvuId: found.id, username: found.username });
} catch (err) {
if (err instanceof Problem && err.slug === "no_such_imvu_account")
throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`);
throw err;
}
}
return out;
};
const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) });
const rating = t.Union([t.Literal("general"), t.Literal("adult")]);
const privacy = t.Union([t.Literal("public"), t.Literal("private")]);
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
const sceneItem = t.Tuple([
@@ -628,7 +740,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
await audited(me.id, "hosted_room.create", String(r.room.n), { name: r.room.name, rating: r.room.rating });
await audited(me.id, "hosted_room.create", String(r.room.n), {
name: r.room.name,
rating: r.room.rating,
privacy: r.room.privacy,
});
set.status = 201;
return { room: hostedView(r) };
},
@@ -641,34 +757,64 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rating,
maxUsers: t.Optional(maxUsers),
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
privacy: t.Optional(privacy),
}),
},
)
.get(
"/hosted-rooms",
async ({ person, query }) => {
const mine = query.mine === "1" || query.mine === "true";
if (mine && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({ adult: await personAdult(person), mine: mine ? person!.id : undefined });
return { rooms: rows.map(hostedView) };
const on = (v?: string) => v === "1" || v === "true";
const mine = on(query.mine);
const favorites = on(query.favorites);
const recent = on(query.recent);
if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({
adult: await personAdult(person),
viewer: person ? await viewerOf(person) : undefined,
mine,
favorites,
recent,
});
return { rooms: await hostedViews(rows, person) };
},
{
query: t.Object({
mine: t.Optional(t.String({ maxLength: 8 })),
favorites: t.Optional(t.String({ maxLength: 8 })),
recent: t.Optional(t.String({ maxLength: 8 })),
}),
},
{ query: t.Object({ mine: t.Optional(t.String({ maxLength: 8 })) }) },
)
.get(
"/hosted-rooms/:n",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
return {
room: await hostedViewFor(r, person),
scene: { contents: r.room.scene, hash: r.room.sceneHash },
myRating: person ? await hosted.myRating(person.id, r.room.n) : 0,
};
},
{ params: hostedParam },
)
.patch(
"/hosted-rooms/:n",
async ({ me, params, body }) => {
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, body));
realtime.hostedChanged(r.room.n, { closed: false, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers });
await audited(me.id, "hosted_room.edit", String(r.room.n), { fields: Object.keys(body) });
return { room: hostedView(r) };
const { moderators: given, ...rest } = body;
const changes: RoomChanges = given
? {
...rest,
moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators),
}
: rest;
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
realtime.hostedChanged(r.room.n, roomInfo(r));
await audited(me.id, "hosted_room.edit", String(r.room.n), {
fields: Object.keys(body),
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
});
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
@@ -679,6 +825,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rating: t.Optional(rating),
maxUsers: t.Optional(maxUsers),
roomPid: t.Optional(roomPid),
privacy: t.Optional(privacy),
moderators: t.Optional(
t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), {
maxItems: maxModerators,
}),
),
}),
},
)
@@ -707,6 +859,134 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
{ signedIn: true, params: hostedParam },
)
// Phase 2: bans, images, favorites, ratings.
.get(
"/hosted-rooms/:n/participants",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { participants: realtime.participants(r.room.n) };
},
{ params: hostedParam },
)
.get(
"/hosted-rooms/:n/bans",
async ({ me, params }) => {
await hostedCall(async () => hosted.managed(await viewerOf(me), params.n));
const bans = await hosted.bans(params.n);
return {
bans: bans.map((b) => ({
imvuId: b.imvuId,
byImvuId: b.byImvuId,
until: b.until?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
})),
};
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/bans/:imvu",
async ({ me, params, set }) => {
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
throw new Problem(404, "not_found", "That account isn't banned here.");
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
set.status = 204;
},
{
signedIn: true,
params: t.Object({
n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }),
imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }),
}),
},
)
.get(
"/hosted-rooms/:n/image",
async ({ person, params, request }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
const img = r.room.imageHash ? await hosted.image(params.n) : undefined;
if (!img) throw new Problem(404, "not_found", "This room has no image.");
const etag = `"${img.hash}"`;
const headers = {
"Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`,
ETag: etag,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
};
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } });
},
{ params: hostedParam },
)
.put(
"/hosted-rooms/:n/image",
async ({ me, params, body }) => {
if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while.");
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
const data = Buffer.from(await body.image.arrayBuffer());
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ image: t.File() }),
beforeHandle({ request }) {
// Refuse big uploads before reading them (with room for the multipart framing).
if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024)
throw new Problem(400, "invalid", "An image is up to 512 KB.");
},
},
)
.delete(
"/hosted-rooms/:n/image",
async ({ me, params }) => {
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
return { room: await hostedViewFor(r, me) };
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
await hosted.favorite(me.id, params.n, true);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
await hosted.favorite(me.id, params.n, false);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/rating",
async ({ me, params, body }) => {
if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room.");
await hosted.rate(me.id, params.n, body.score);
return { room: await hostedViewFor(r, me), myRating: body.score };
},
{ signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) },
)
.delete(
"/hosted-rooms/:n/rating",
async ({ me, params, set }) => {
await hosted.rate(me.id, params.n, null);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Decorations over IMVU rooms (docs/products.md, "Decorations").
.get(
"/decorations",
+264 -20
View File
@@ -1,16 +1,35 @@
// 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.
// 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, count, desc, eq, sql, schema, type Database, type SceneItem } from "@t5de/db";
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, imvuLink, user } = schema;
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 {
@@ -26,6 +45,10 @@ export class HostedError extends Error {
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[]) =>
@@ -51,6 +74,14 @@ function cleanDescription(s: string): string {
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;
@@ -58,9 +89,23 @@ export type NewRoom = {
rating: Rating;
maxUsers?: number;
imvuId: number;
privacy?: Privacy;
};
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers">>;
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) {}
@@ -77,24 +122,58 @@ export class HostedRooms {
private visible = sql`coalesce(${user.banned}, false) = false`;
/** Open rooms, newest first: all those the viewer may see, or (mine) the viewer's own. */
async list({ adult, mine }: { adult: boolean; mine?: string }) {
const rows = await this.shown()
.where(
and(
this.visible,
eq(hostedRoom.closed, false),
mine ? eq(hostedRoom.ownerId, mine) : undefined,
adult || mine ? undefined : eq(hostedRoom.rating, "general"),
),
)
/** 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);
return rows;
}
/** 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) {
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.");
@@ -107,8 +186,7 @@ export class HostedRooms {
/** 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)));
if (!r) return undefined;
return { closed: r.room.closed, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers };
return r ? roomInfo(r) : undefined;
}
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
@@ -138,6 +216,7 @@ export class HostedRooms {
roomPid: body.roomPid,
rating: body.rating,
maxUsers: body.maxUsers ?? 10,
privacy: body.privacy ?? "public",
scene: [],
sceneHash: sceneHash(body.roomPid, []),
})
@@ -147,7 +226,7 @@ export class HostedRooms {
}
/** The owner's room, open (for changes). */
private async own(ownerId: string, n: number) {
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;
@@ -158,6 +237,13 @@ export class HostedRooms {
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({
@@ -165,6 +251,8 @@ export class HostedRooms {
...(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))
@@ -190,4 +278,160 @@ export class HostedRooms {
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,
};
}
+298 -22
View File
@@ -24,7 +24,7 @@
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat"}],
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}],
// "sequence": s} (the latest message number)
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
// "not_linked" | "too_many", "message": "..."}
@@ -32,24 +32,50 @@
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
// was unlinked or changed, or the room became adult)
//
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
/** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = { closed: boolean; adult: boolean; maxUsers: number };
export type HostedRoomInfo = {
closed: boolean;
adult: boolean;
maxUsers: number;
privacy: "public" | "private";
ownerImvuId: number;
moderators: number[];
name: string;
ownerName: string;
};
/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
export const hostedBase = 2_100_000_000;
/** The highest room number: chat IDs must stay XML-RPC integers. */
export const maxHostedN = 2_147_483_647 - hostedBase;
type Participant = { imvu_id: number; seat: number };
type Participant = { imvu_id: number; seat: number; name: string };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages.
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
type Session = {
userId: string;
imvuId: number;
@@ -62,13 +88,21 @@ type Session = {
hosted: Set<number>;
tokens: number;
refilled: number;
watching: Set<number>;
watchGen: number;
invites: number;
invitesRefilled: number;
send: (message: object) => void;
close: () => void;
};
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
type Hosted = { members: Map<string, { imvuId: number; seat: number }>; sequence: number };
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
export type RealtimeDeps = {
/** The person an IMVU account is linked to. */
@@ -79,6 +113,14 @@ export type RealtimeDeps = {
room?: (instanceId: string) => void;
/** A hosted room (undefined: there's none by that number). */
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
/** Whether an IMVU account is banned from a hosted room now. */
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
/** Someone was booted (and maybe banned): store the ban, log it. */
hostedBoot?: (boot: Boot) => Promise<void>;
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
accountName?: (imvuId: number) => Promise<string>;
};
const maxChats = 16;
@@ -89,6 +131,12 @@ const maxHostedPerSession = 5;
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
const sendBurst = 20;
const sendPerSecond = 10;
const maxWatched = 100;
const inviteLifetime = 10 * 60 * 1000;
const inviteBurst = 10;
const invitesPerMinute = 10;
const maxNote = 200;
const maxBanMinutes = 10 * 366 * 24 * 60;
const roomErrors = {
not_linked: "Sign in to T5DE with a linked IMVU account first.",
@@ -97,17 +145,42 @@ const roomErrors = {
adult: "This room is for adults.",
full: "This room is full.",
too_many: "You're in too many rooms.",
banned: "You're banned from this room.",
private: "This room is private.",
} as const;
// Reasons in room.boot_result and room.invite_result.
const actionErrors = {
not_linked: roomErrors.not_linked,
not_found: roomErrors.not_found,
closed: roomErrors.closed,
forbidden: "You can't do that in this room.",
not_in_room: "Join the room first.",
banned: "They're banned from this room.",
adult: "This room is for adults.",
rate_limited: "Too many invitations. Try again in a minute.",
} as const;
export class Realtime {
private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private watchers = new Map<number, Set<string>>();
private invites = new Map<string, Invite>();
private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>;
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = { adult: async () => false, room: () => {}, hostedRoom: async () => undefined, ...d };
this.deps = {
adult: async () => false,
room: () => {},
hostedRoom: async () => undefined,
hostedBanned: async () => false,
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
...d,
};
}
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
@@ -151,6 +224,10 @@ export class Realtime {
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
watching: new Set(),
watchGen: 0,
invites: inviteBurst,
invitesRefilled: Date.now(),
send,
close,
});
@@ -158,7 +235,10 @@ export class Realtime {
close(id: string) {
const s = this.sessions.get(id);
if (s) this.leaveAllHosted(id, s, false);
if (s) {
this.leaveAllHosted(id, s, false);
this.unwatch(id, s);
}
this.sessions.delete(id);
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
}
@@ -243,11 +323,11 @@ export class Realtime {
return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size;
}
/** A hosted room's participants: each IMVU account once, with the seat it last took. */
private participants(n: number): Participant[] {
const seats = new Map<number, number>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, m.seat);
return [...seats].map(([imvuId, seat]) => ({ imvu_id: imvuId, seat }));
/** A hosted room's participants: each IMVU account once, with the seat it last took and its IMVU name. */
participants(n: number): Participant[] {
const seats = new Map<number, { seat: number; name: string }>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name });
return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name }));
}
private toRoom(n: number, message: object, except?: string) {
@@ -255,23 +335,50 @@ export class Realtime {
}
private participantsChanged(n: number, except?: string) {
this.toRoom(n, { type: "room.participants", room: n, participants: this.participants(n) }, except);
const participants = this.participants(n);
this.toRoom(n, { type: "room.participants", room: n, participants }, except);
const occupancy = { type: "room.occupancy", room: n, participants };
for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy);
}
/** Takes a session out of a hosted room; tell: say so (room.closed), when it didn't ask to leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean) {
/** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to
* leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n });
if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) });
this.participantsChanged(n);
}
private toAccount(imvuId: number, message: object) {
for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message);
}
private unwatch(id: string, s: Session) {
for (const n of s.watching) {
const w = this.watchers.get(n);
w?.delete(id);
if (w?.size === 0) this.watchers.delete(n);
}
s.watching.clear();
}
private leaveAllHosted(id: string, s: Session, tell: boolean) {
for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell);
}
/** A hosted room closed: everyone in it is told, and its state is dropped. */
hostedClosed(n: number) {
const watching = this.watchers.get(n);
if (watching) {
this.watchers.delete(n);
for (const w of watching) {
const s = this.sessions.get(w);
s?.watching.delete(n);
s?.send({ type: "room.occupancy", room: n, participants: [] });
}
}
for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id);
const room = this.hosted.get(n);
if (!room) return;
this.hosted.delete(n);
@@ -293,7 +400,12 @@ export class Realtime {
}
}
private async joinHosted(id: string, s: Session, n: number): Promise<object> {
/** Whether an account is the room's owner account, or one of its moderators. */
private static staff(info: HostedRoomInfo, imvuId: number) {
return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) };
}
private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise<object> {
const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] });
if (!s.imvuId) return refuse("not_linked");
const imvuId = s.imvuId;
@@ -301,23 +413,164 @@ export class Realtime {
if (!info) return refuse("not_found");
if (info.closed) return refuse("closed");
const adult = await this.deps.adult(s.userId, imvuId);
const name = await this.deps.accountName(imvuId);
const banned = await this.deps.hostedBanned(n, imvuId);
// The session may have closed or changed accounts while waiting.
if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked");
s.adult = adult;
if (info.adult && !s.adult) return refuse("adult");
if (banned) return refuse("banned");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
// A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder.
let admit: Invite | undefined;
const staff = Realtime.staff(info, imvuId);
if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) {
const inv = typeof invite === "string" ? this.invites.get(invite) : undefined;
if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private");
admit = inv;
}
if (s.hosted.size >= maxHostedPerSession) return refuse("too_many");
const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId));
if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full");
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0 }));
room.members.set(id, { imvuId, seat: 0 });
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0, admitted: new Set() }));
if (admit) {
this.invites.delete(admit.id); // single use
room.admitted.add(imvuId);
}
room.members.set(id, { imvuId, seat: 0, name });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId);
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */
private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) {
const target = m.imvu_id;
const ban = m.ban === undefined ? false : m.ban;
const minutes = m.minutes === undefined ? 0 : m.minutes;
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
if (typeof ban !== "boolean") return { type: "error", message: "bad ban" };
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes)
return { type: "error", message: "bad minutes" };
const result = (reason?: keyof typeof actionErrors) => ({
type: "room.boot_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }),
});
if (!s.imvuId) return result("not_linked");
const actor = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked");
const me = Realtime.staff(info, actor);
const them = Realtime.staff(info, target);
// Nobody boots the owner; moderators don't boot moderators; nobody boots themselves.
if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor)
return result("forbidden");
await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes });
const room = this.hosted.get(n);
room?.admitted.delete(target);
for (const [sid, member] of [...(room?.members ?? [])]) {
const other = this.sessions.get(sid);
if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted");
}
return result();
}
/** Whether a session may send another invitation now (10, then 10 a minute). */
private allowInvite(s: Session): boolean {
const now = Date.now();
s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute);
s.invitesRefilled = now;
if (s.invites < 1) return false;
s.invites -= 1;
return true;
}
/** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */
private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise<object | undefined> {
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
const result = (reason?: keyof typeof actionErrors, invite?: string) => ({
type: "room.invite_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }),
});
if (!s.imvuId) return result("not_linked");
const from = s.imvuId;
if (target === from) return { type: "error", message: "bad imvu_id" };
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
const banned = await this.deps.hostedBanned(n, target);
if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked");
const me = Realtime.staff(info, from);
if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id))
return result(info.privacy === "private" ? "forbidden" : "not_in_room");
if (banned) return result("banned");
const targets = [...this.sessions.values()].filter((t) => t.imvuId === target);
if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult");
if (!this.allowInvite(s)) return result("rate_limited");
const now = Date.now();
for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k);
const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
s.send(result(undefined, inviteId));
if (targets.length === 0) {
s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true });
return;
}
this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false });
const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName };
for (const t of targets) if (!info.adult || t.adult) t.send(message);
}
/** room.invite_reply: the invited account answers (the first answer counts). */
private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined {
const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined;
if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" };
if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" };
if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" };
if (inv.answered) return { type: "error", message: "already answered" };
inv.answered = true;
inv.accepted = m.accept;
if (!m.accept) this.invites.delete(inv.id);
// eslint-disable-next-line no-control-regex
const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote);
this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) });
}
/** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */
private async watch(id: string, s: Session, rooms: unknown): Promise<object | undefined> {
if (
!Array.isArray(rooms) ||
rooms.length > maxWatched ||
!rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN)
)
return { type: "error", message: "bad rooms" };
const gen = ++s.watchGen;
this.unwatch(id, s);
const ns = [...new Set(rooms as number[])];
const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n)));
if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came
for (const [i, n] of ns.entries()) {
const info = infos[i];
if (!info || info.closed || (info.adult && !s.adult)) continue;
const me = Realtime.staff(info, s.imvuId);
if (info.privacy === "private" && !me.owner && !me.moderator) continue;
s.watching.add(n);
let w = this.watchers.get(n);
if (!w) this.watchers.set(n, (w = new Set()));
w.add(id);
s.send({ type: "room.occupancy", room: n, participants: this.participants(n) });
}
}
/** Whether a session may send another room message now (a bucket of tokens, refilled over time). */
private allowSend(s: Session): boolean {
const now = Date.now();
@@ -358,6 +611,12 @@ export class Realtime {
seat?: unknown;
mount?: unknown;
message?: unknown;
invite?: unknown;
ban?: unknown;
minutes?: unknown;
accept?: unknown;
note?: unknown;
rooms?: unknown;
};
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
@@ -410,11 +669,28 @@ export class Realtime {
this.changed([chat]);
return;
}
if (m.type === "room.join" || m.type === "room.leave" || m.type === "room.seat" || m.type === "room.send") {
if (m.type === "room.watch") return this.watch(id, s, m.rooms);
if (m.type === "room.invite_reply") return this.inviteReply(s, m);
if (
m.type === "room.join" ||
m.type === "room.leave" ||
m.type === "room.seat" ||
m.type === "room.send" ||
m.type === "room.boot" ||
m.type === "room.invite"
) {
const n = m.room;
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" };
if (m.type === "room.join") return this.joinHosted(id, s, n);
if (m.type === "room.leave") return void this.leaveHosted(id, s, n, false);
if (m.type === "room.join") return this.joinHosted(id, s, n, m.invite);
if (m.type === "room.boot") return this.bootHosted(id, s, n, m);
if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id);
if (m.type === "room.leave") {
this.leaveHosted(id, s, n, false);
// Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room.
const room = this.hosted.get(n);
if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId);
return;
}
if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message);
const seat = m.seat;
if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" };
+338 -4
View File
@@ -1,6 +1,6 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
@@ -95,7 +95,7 @@ async function proxy(p: Person, imvuId: number) {
};
const join = (room: number) =>
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
return { ws, got, send, last, join };
return { ws, got, send, last, join, ask };
}
async function waitFor(ok: () => boolean) {
@@ -237,11 +237,13 @@ test("joining, seats and messages in a hosted room", async () => {
// Dee and Eli join; each sees the other.
const dj = await d.join(n);
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0 }], sequence: 0 });
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 });
const ej = await e.join(n);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
// Full at two people; joining again (a reconnect) answers the same.
expect((await f.join(n)).reason).toBe("full");
@@ -320,3 +322,335 @@ test("a room made adult sends non-adults out; an unlinked account leaves its roo
g.ws.close();
h.ws.close();
});
// ---- Phase 2 ----
test("moderators boot and ban; owners lift bans", async () => {
const own = await person("ownr", { id: 601 });
const mod = await person("modr", { id: 602 });
const usr = await person("usrr", { id: 603 });
const md2 = await person("mod2", { id: 604 });
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
const many = Array.from({ length: 21 }, (_, i) => i + 1);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
"no_such_imvu_account",
);
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
expect(set.status).toBe(200);
expect(set.json.room.moderators).toEqual([602, 604, 605]);
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
const o = await proxy(own, 601);
const m = await proxy(mod, 602);
const u = await proxy(usr, 603);
const m2 = await proxy(md2, 604);
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
// Who may boot whom.
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
// A boot: out, but may come back.
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
expect((await u.join(n)).type).toBe("room.joined");
// A ban: out, and refused.
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
// Bans: listed for the owner and moderators; lifted by the owner only.
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
expect((await u.join(n)).type).toBe("room.joined");
// A ban for a while; the owner may boot a moderator.
await boot(o, 603, { ban: true, minutes: 60 });
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
expect((await boot(o, 604)).ok).toBe(true);
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
// Logged.
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
const actions = log.map((e) => e.action);
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
expect(actions).toContain("hosted_room.unban");
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
for (const p of [o, m, u, m2]) p.ws.close();
});
test("private rooms and invitations", async () => {
const pam = await person("pamm", { id: 701 });
const quin = await person("quin", { id: 702 });
const rex = await person("rexx", { id: 703 }, { id: 705 });
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
// Listed only for the owner and moderators.
const listed = async (p?: Person, q = "") =>
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
expect(await listed()).toBe(false);
expect(await listed(rex)).toBe(false);
expect(await listed(pam)).toBe(true);
expect(await listed(quin)).toBe(true);
expect(await listed(pam, "?mine=1")).toBe(true);
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
const p = await proxy(pam, 701);
const q = await proxy(quin, 702);
const r = await proxy(rex, 703);
expect((await r.join(n)).reason).toBe("private");
expect((await q.join(n)).type).toBe("room.joined");
const invite = (from: typeof p, room: number, imvu_id: number) =>
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
const joinWith = (x: typeof p, invite: string) =>
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
// Invited: the invitee's sessions are told; it answers; the inviter hears.
const sent = await invite(p, n, 703);
expect(sent.ok).toBe(true);
const id: string = sent.invite;
await waitFor(() => r.last("room.invited", n) !== undefined);
expect(r.last("room.invited", n)).toEqual({
type: "room.invited",
room: n,
invite: id,
from: 701,
room_name: "Hideout",
owner_name: "pammAv",
});
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
await waitFor(() => p.last("room.invite_answer") !== undefined);
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
r.send({ type: "room.invite_reply", invite: id, accept: false });
await waitFor(() => r.got.some((x) => x.message === "already answered"));
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
expect((await joinWith(r, id)).type).toBe("room.joined");
r.ws.close();
const r2 = await proxy(rex, 703);
expect((await r2.join(n)).type).toBe("room.joined");
r2.send({ type: "room.leave", room: n });
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
expect((await joinWith(r2, id)).reason).toBe("private");
// Declined.
const second = (await invite(q, n, 703)).invite;
await waitFor(() => r2.got.some((x) => x.invite === second));
r2.send({ type: "room.invite_reply", invite: second, accept: false });
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
expect((await joinWith(r2, second)).reason).toBe("private");
// Nobody online as that account; banned accounts aren't invited.
const off = await invite(p, n, 705);
expect(off.ok).toBe(true);
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
type: "room.invite_answer",
invite: off.invite,
to: 705,
accept: false,
offline: true,
});
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
// Public rooms: anyone in it invites.
expect((await r2.join(pub)).type).toBe("room.joined");
expect((await invite(r2, pub, 701)).ok).toBe(true);
await waitFor(() => p.last("room.invited", pub) !== undefined);
for (const x of [p, q, r2]) x.ws.close();
});
const png = (bytes = 64) => {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return b;
};
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
const form = new FormData();
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
method: "PUT",
headers: { Origin: webURL, Cookie: p.cookie },
body: form,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
test("room images", async () => {
const ivy = await person("ivyy", { id: 801 });
const jon = await person("jonn", { id: 802 });
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
expect((await upload(jon, n, png())).status).toBe(403);
const gif = new TextEncoder().encode("GIF89a................................");
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect((await upload(ivy, n, svg)).status).toBe(400);
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
const up = await upload(ivy, n, png());
expect(up.status).toBe(200);
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(got.status).toBe(200);
expect(got.headers.get("content-type")).toBe("image/png");
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
const etag = got.headers.get("etag")!;
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
expect((await upload(ivy, n, jpeg)).status).toBe(200);
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(again.headers.get("content-type")).toBe("image/jpeg");
expect(again.headers.get("etag")).not.toBe(etag);
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
});
test("favorites, ratings and recent rooms", async () => {
const kim = await person("kimm", { id: 901 });
const lou = await person("louu", { id: 902 });
const mia = await person("miaa", { id: 903 });
const ns: number[] = [];
for (const name of ["One", "Two", "Three"])
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
const [a, b, c] = ns as [number, number, number];
// Favorites.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
expect(await favs(lou)).toEqual([c, a]);
expect(await favs(mia)).toEqual([]);
const flag = async (p: Person | undefined, n: number) =>
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
expect(await flag(lou, a)).toBe(true);
expect(await flag(mia, a)).toBe(false);
expect(await flag(undefined, a)).toBe(false);
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect(await favs(lou)).toEqual([c]);
// Ratings.
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
expect(one.json.myRating).toBe(4);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
// Recent rooms: joined, latest first, each once.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
const l = await proxy(lou, 902);
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
for (const n of [a, b, c]) {
await l.join(n);
await Bun.sleep(20);
}
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
expect(await recent()).toEqual([c, b, a]);
l.send({ type: "room.leave", room: a });
await Bun.sleep(30);
await l.join(a);
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
expect(await recent()).toEqual([a, c, b]);
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
expect(visit.map((v) => v.imvuId)).toEqual([902]);
l.ws.close();
});
test("watching rooms' occupancy", async () => {
const ned = await person("nedd", { id: 1001 });
const ola = await person("olaa", { id: 1002 });
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
const w = await proxy(ola, 1002);
const n = await proxy(ned, 1001);
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
// The private room's owner may watch it.
n.send({ type: "room.watch", rooms: [hidden] });
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
await n.join(a);
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
n.send({ type: "room.seat", room: a, seat: 2 });
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
n.send({ type: "room.leave", room: a });
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
await n.join(hidden);
await Bun.sleep(50);
expect(w.last("room.occupancy", hidden)).toBeUndefined();
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
// Replaced; closing tells watchers it's empty.
w.send({ type: "room.watch", rooms: [b] });
await Bun.sleep(50);
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
await n.join(a);
await n.join(b);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
w.ws.close();
n.ws.close();
});
+34 -2
View File
@@ -6,7 +6,9 @@ import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { api, getMe, unwrap } from "@/lib/api";
import { closeRoom, saveRoom } from "../../actions";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { closeRoom, removeImage, saveImage, saveRoom } from "../../actions";
import { RoomFields } from "../../room-fields";
export const metadata: Metadata = { title: "Edit room" };
@@ -40,7 +42,7 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string
<CardContent>
<ActionForm action={saveRoom} fields={{ n: room.n }}>
<FieldGroup>
<RoomFields room={room} canAdult={links.some((l) => l.adult)} />
<RoomFields room={room} canAdult={links.some((l) => l.adult)} moderators />
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
@@ -49,6 +51,36 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Image</CardTitle>
<CardDescription>Shown in room lists.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{room.image && (
// The room's image, from the hub.
<img src={room.image} alt="" className="aspect-[332/281] w-full max-w-xs rounded-md bg-muted object-cover" />
)}
<ActionForm action={saveImage} fields={{ n: room.n }}>
<Field>
<FieldLabel htmlFor="image">{room.image ? "Replace" : "Upload"}</FieldLabel>
<Input id="image" name="image" type="file" accept="image/png,image/jpeg" required className="max-w-md" />
<FieldDescription>PNG or JPEG, up to 512 KB.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Uploading…">Save image</SubmitButton>
</div>
</ActionForm>
{room.image && (
<ActionForm action={removeImage} fields={{ n: room.n }}>
<SubmitButton variant="outline" pendingLabel="Removing…">
Remove image
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Close room</CardTitle>
+50
View File
@@ -5,6 +5,12 @@ import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const));
const moderators = (form: FormData) =>
field(form, "moderators")
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
/** Makes a hosted room, then goes back to the list. */
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
@@ -17,6 +23,7 @@ export async function createRoom(_: ActionState, form: FormData): Promise<Action
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
imvuId: Number(field(form, "imvuId")),
privacy: privacy(form),
}),
);
revalidatePath("/rooms");
@@ -36,6 +43,8 @@ export async function saveRoom(_: ActionState, form: FormData): Promise<ActionSt
roomPid: Number(field(form, "roomPid")),
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
privacy: privacy(form),
moderators: moderators(form),
}),
);
revalidatePath("/rooms");
@@ -44,6 +53,47 @@ export async function saveRoom(_: ActionState, form: FormData): Promise<ActionSt
redirect("/rooms");
}
/** The room's image: a PNG or JPEG, up to 512 KB. */
export async function saveImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const image = form.get("image");
if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." };
if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." };
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.put({ image }));
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
return "Image saved.";
});
}
export async function removeImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.delete());
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
});
}
/** Stars a room, or (favorite=0) unstars it. */
export async function setFavorite(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
const room = api["hosted-rooms"]({ n }).favorite;
unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete()));
revalidatePath("/rooms");
});
}
export async function rateRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) }));
revalidatePath("/rooms");
});
}
/** Closes a room: everyone in it is sent out. */
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
+12 -4
View File
@@ -4,6 +4,7 @@ import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
import { RoomActions } from "./room-actions";
export const metadata: Metadata = { title: "Rooms" };
@@ -39,7 +40,7 @@ export default async function Rooms() {
<ul className="grid gap-4 sm:grid-cols-2">
{hosted.map((r) => (
<li key={r.n}>
<HostedCard room={r} mine={own.has(r.n)} />
<HostedCard room={r} mine={own.has(r.n)} signedIn={!!me} />
</li>
))}
</ul>
@@ -93,13 +94,18 @@ export default async function Rooms() {
);
}
function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
return (
<Card className="h-full">
<CardContent className="space-y-3">
<Card className="h-full overflow-hidden py-0">
{r.image && (
// The room's image, from the hub.
<img src={r.image} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
<CardContent className="space-y-3 py-6">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.name}</span>
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
{r.privacy === "private" && <Tag>Private</Tag>}
{r.occupancy > 0 && (
<Tag tone="online" dot>
{r.occupancy} here
@@ -113,8 +119,10 @@ function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
</div>
<p className="text-xs text-muted-foreground">
{r.ownerName} · {r.occupancy}/{r.maxUsers}
{r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{signedIn && <RoomActions n={r.n} favorite={r.favorite} canRate={!mine} />}
</CardContent>
</Card>
);
+28
View File
@@ -0,0 +1,28 @@
"use client";
// A hosted room card's favorite star and rating buttons.
import { Star } from "lucide-react";
import { ActionForm, SubmitButton } from "@/components/forms";
import { cn } from "@/lib/utils";
import { rateRoom, setFavorite } from "./actions";
export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) {
return (
<div className="flex flex-wrap items-center gap-3">
<ActionForm action={setFavorite} fields={{ n, favorite: favorite ? "0" : "1" }} className="space-y-0">
<SubmitButton variant="ghost" size="icon" aria-label={favorite ? "Unstar" : "Star"} title={favorite ? "Unstar" : "Star"}>
<Star className={cn("size-4", favorite && "fill-current text-amber-500")} />
</SubmitButton>
</ActionForm>
{canRate && (
<ActionForm action={rateRoom} fields={{ n }} className="flex items-center gap-0.5 space-y-0">
<span className="mr-1 text-xs text-muted-foreground">Rate</span>
{[1, 2, 3, 4, 5].map((k) => (
<SubmitButton key={k} name="score" value={String(k)} variant="ghost" size="xs" aria-label={`${k} of 5`}>
{k}
</SubmitButton>
))}
</ActionForm>
)}
</div>
);
}
+19 -1
View File
@@ -5,7 +5,10 @@ import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Textarea } from "@/components/ui/textarea";
import type { HostedRoom } from "@/lib/api";
export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: boolean }) {
/** The moderators as the edit field shows them: names where known, else IDs. */
const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", ");
export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) {
return (
<>
<Field>
@@ -55,6 +58,21 @@ export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: bo
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="privacy">Privacy</FieldLabel>
<NativeSelect id="privacy" name="privacy" defaultValue={room?.privacy ?? "public"} className="w-64">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
<FieldDescription>Private: unlisted; only you, moderators and people invited get in.</FieldDescription>
</Field>
{moderators && room && (
<Field>
<FieldLabel htmlFor="moderators">Moderators</FieldLabel>
<Input id="moderators" name="moderators" defaultValue={moderatorList(room)} placeholder="IMVU names or IDs" />
<FieldDescription>Up to 20, comma-separated. They can boot and ban.</FieldDescription>
</Field>
)}
</>
);
}