Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
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:
1 parent
43183ec7ee
commit
76c8e0342e
16 files changed
+3851
-73
No files matched your search
+294
-14
@@ -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
@@ -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
@@ -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" };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
-4
@@ -57,14 +57,16 @@ the `GET`s also signed out.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
|
||||
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200); adult ones for adults only. `?mine=1`: the person's own. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}}`; 404, or 410 `closed` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?}` → `{room}`. Made adult: non-adults in it are sent out. |
|
||||
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
|
||||
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. |
|
||||
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
|
||||
| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" |
|
||||
|
||||
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||
privacy, moderators, moderatorNames, image, favorite, score, ratings,
|
||||
createdAt}`; `instanceId` is `<owner IMVU ID>-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
|
||||
|
||||
## Decorations
|
||||
|
||||
+115
-1
@@ -6,7 +6,8 @@ room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what
|
||||
hears of hosted rooms.
|
||||
|
||||
Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the
|
||||
owner's furniture), occupancy in the room list. Later: moderators, bans and boots, invitations, private rooms.
|
||||
owner's furniture), occupancy in the room list. Phase 2 (below): moderators, boots and bans, private rooms,
|
||||
invitations, images, favorites, ratings, recent rooms, occupancy pushed to watchers, participant names.
|
||||
|
||||
## IDs
|
||||
|
||||
@@ -81,3 +82,116 @@ stored (design.md: chat retention).
|
||||
`node` mount, and nothing more.
|
||||
- **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/<owner>-<2100000000+n>`
|
||||
queues are answered from `room.participants`.
|
||||
|
||||
## Phase 2
|
||||
|
||||
Moderators, boots and bans, private rooms, invitations, images, favorites, ratings, recent rooms, occupancy pushed
|
||||
to watchers, and participants' names. Everything in phase 1 still holds; this adds to it.
|
||||
|
||||
### Rooms (REST)
|
||||
|
||||
A room (in every answer that has one) also has:
|
||||
|
||||
| Field | |
|
||||
| --- | --- |
|
||||
| `privacy` | `"public"` (default) or `"private"` |
|
||||
| `moderators` | IMVU account IDs, up to 20, in the order the owner gave them (never the owner's own account) |
|
||||
| `moderatorNames` | their IMVU names, same order: the name of the linked account, or as IMVU gave it when added by name; `""` when not known |
|
||||
| `image` | `<API URL>/api/v1/hosted-rooms/<n>/image?v=<16 hex>` (absolute; `v` changes with the image), or `null` |
|
||||
| `favorite` | whether it's one of the signed-in person's favorites (`false` signed out) |
|
||||
| `score` | the average rating, 1-5, two decimals; `0` if nobody rated it. (Named `score`, not `rating`: `rating` is already the room's `general`/`adult`.) |
|
||||
| `ratings` | how many people rated it |
|
||||
|
||||
Who's who: the room's **owner** is, over REST, the person who made it; over realtime, its owner IMVU account
|
||||
(`ownerImvuId`). A **moderator** is an IMVU account in `moderators`; over REST, a person is a moderator if one of their
|
||||
linked accounts is.
|
||||
|
||||
| Route | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | also takes `privacy?` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) also takes `privacy?` and `moderators?`: up to 20 entries, each an IMVU ID (integer, or a string of digits) or an IMVU name (looked up: linked accounts first, then IMVU, rate-limited; unknown: 404 `no_such_imvu_account`). Replaces the list; duplicates and the owner's account are dropped. More than 20: 400 `invalid`. Making a room private sends no one out. |
|
||||
| `GET /api/v1/hosted-rooms` | public rooms, and private ones the person owns or moderates. `?mine=1`: the person's own (private too). `?favorites=1`: their favorites, latest starred first. `?recent=1`: the last 20 distinct rooms they joined (any of their accounts), latest first. Those three need a signed-in person (401 `signed_out`); adult rooms only for adults, as before; closed and hidden rooms left out. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | anyone may get a private room (it's unlisted, not secret). Also `myRating`: the person's own rating, `0` if none or signed out. |
|
||||
| `GET /api/v1/hosted-rooms/:n/participants` | (anyone who may `GET /:n`, also signed out; same 404/410) `{participants: [{imvu_id, seat, name}]}`, as in `room.participants` |
|
||||
| `GET /api/v1/hosted-rooms/:n/bans` | (owner or moderator) `{bans: [{imvuId, byImvuId, until, createdAt}]}`: bans in force, latest first; `until` ISO time or `null` (for good). Others: 403 `forbidden`. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/bans/:imvu` | (owner only) lifts a ban: 204; 404 if there's none. Logged (`hosted_room.unban`). |
|
||||
| `PUT /api/v1/hosted-rooms/:n/image` | (owner) multipart, field `image`: a PNG or JPEG (by its magic bytes, whatever the declared type), up to 512 KB; else 400 `invalid`. Stored as uploaded. → `{room}`. 30 an hour. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/image` | (owner) → `{room}` with `image: null` |
|
||||
| `GET /api/v1/hosted-rooms/:n/image` | (anyone who may `GET /:n`) the image, with its `Content-Type`, `ETag` (`If-None-Match` answers 304), `Cache-Control: public, max-age=3600` (`private` for adult rooms), `X-Content-Type-Options: nosniff`; 404 if none |
|
||||
| `PUT /api/v1/hosted-rooms/:n/favorite` | (signed in, a room they may see) stars it: 204, also if starred already |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/favorite` | 204 |
|
||||
| `PUT /api/v1/hosted-rooms/:n/rating` | `{score}` (integer 1-5) → `{room, myRating}`; replaces their earlier rating. Owners can't rate their own room (403 `forbidden`). |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/rating` | removes their rating: 204 |
|
||||
|
||||
A visit is recorded when an IMVU account joins a hosted room (a new member; not a re-join of a session already in
|
||||
it): per person, the room, the account and the time; the latest 50 per person are kept.
|
||||
|
||||
### Realtime
|
||||
|
||||
**Participants** everywhere (`room.joined`, `room.participants`, `room.occupancy`, `GET .../participants`) are
|
||||
`{"imvu_id": X, "seat": k, "name": "..."}`; `name` is the IMVU username of the hub's verified link for that account
|
||||
(as of when it joined), `""` if unknown.
|
||||
|
||||
**Joining** (`room.join`) may carry `"invite": id`. New `room.error` reasons, checked in this order after phase 1's
|
||||
`not_linked`, `not_found`, `closed`, `adult`: `"banned"` (a ban in force on the session's account), then `"private"`
|
||||
(a private room, and the account isn't its owner account, a moderator, already let in, or holding an accepted,
|
||||
unexpired invite for this room and account), then `too_many`, `full`.
|
||||
|
||||
An invite used to join is spent, and the account is **let in**: it may join again without one (a reconnect, another
|
||||
session) until it sends `room.leave` and none of its sessions is still in the room, or it's booted. (A connection
|
||||
dropping doesn't end it. It's kept in memory: a hub restart ends it.)
|
||||
|
||||
**Boot and ban**, from the owner account or a moderator (needn't be in the room):
|
||||
|
||||
- proxy → hub `{"type": "room.boot", "room": n, "imvu_id": X, "ban"?: false, "minutes"?: 0}`. `ban` true stores a
|
||||
ban; `minutes` 0 (default) is for good, else 1 to 5,270,400 (about ten years). Malformed: `{"type": "error",
|
||||
"message": "bad imvu_id" | "bad ban" | "bad minutes" | "bad room"}`.
|
||||
- hub → proxy (the sender) `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": true, "ban": bool}`, or
|
||||
`{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": false, "reason": "...", "message": "..."}` with
|
||||
`reason` `"not_linked"`, `"not_found"`, `"closed"` or `"forbidden"`: the sender isn't the owner account or a
|
||||
moderator; X is the owner account (nobody boots the owner); X is a moderator and the sender isn't the owner
|
||||
account; or X is the sender.
|
||||
- Every session of X in the room gets `{"type": "room.closed", "room": n, "reason": "booted"}` and is out; the others
|
||||
get `room.participants`; watchers `room.occupancy`. A boot without a ban doesn't stop X coming back (except into
|
||||
a private room, which needs a new invite).
|
||||
- Logged: `hosted_room.boot` or `hosted_room.ban`, target the room number, detail `{imvu: X, by: sender's IMVU ID}`
|
||||
(and `minutes` for a ban), actor the sender's person. A banned moderator stays a moderator (the owner removes them
|
||||
from `moderators`).
|
||||
|
||||
**Invitations**:
|
||||
|
||||
- proxy → hub `{"type": "room.invite", "room": n, "imvu_id": X}`: from a session in the room (public rooms), or from
|
||||
the owner account or a moderator, in it or not (private rooms). 10, then 10 a minute per session.
|
||||
- hub → proxy (the sender) `{"type": "room.invite_result", "room": n, "imvu_id": X, "ok": true, "invite": id}`, or
|
||||
`ok: false` with `reason` `"not_linked"`, `"not_found"`, `"closed"`, `"forbidden"` (private room, not owner or
|
||||
moderator), `"not_in_room"` (public room, the sender isn't in it), `"banned"` (X is banned from the room),
|
||||
`"adult"` (an adult room, and none of X's sessions counts as adult) or `"rate_limited"`, and `message`. X being the
|
||||
sender: `{"type": "error", "message": "bad imvu_id"}`.
|
||||
- When `ok`: every session whose IMVU account is X gets `{"type": "room.invited", "room": n, "invite": id, "from":
|
||||
sender's IMVU ID, "room_name": "...", "owner_name": "..."}` (`owner_name`: the owner account's IMVU name). If X has
|
||||
no session, the sender instead gets `{"type": "room.invite_answer", "invite": id, "to": X, "accept": false,
|
||||
"offline": true}` right after the result, and the invite is dropped.
|
||||
- proxy (X's) → hub `{"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."}`. The first answer
|
||||
counts. `note`: control characters become spaces, trimmed, up to 200 characters, left out when empty. Errors:
|
||||
`{"type": "error", "message": "no such invite" | "already answered" | "bad accept" | "bad note"}` (`no such invite`
|
||||
also when it expired or the session's account isn't X).
|
||||
- hub → proxy (every session of the sender's IMVU account) `{"type": "room.invite_answer", "invite": id, "to": X,
|
||||
"accept": bool, "note"?: "..."}`.
|
||||
- An invite: single use, valid 10 minutes from when it was made (accepting doesn't extend it), for that room and
|
||||
account. Kept in memory only; closing the room drops its invites. `invite` ids are opaque strings (base64url).
|
||||
|
||||
**Occupancy**:
|
||||
|
||||
- proxy → hub `{"type": "room.watch", "rooms": [n, ...]}`: replaces the session's watched set (up to 100; `[]`
|
||||
stops). Not a list of room numbers, or over 100: `{"type": "error", "message": "bad rooms"}`. Rooms the session
|
||||
may not see are left out silently: no such room, closed or hidden; adult rooms for a session whose account isn't
|
||||
adult; private rooms unless its account is the owner account or a moderator. That's checked when it watches.
|
||||
- hub → proxy, once per watched room right after, then whenever the room's participants change (join, leave, seat,
|
||||
boot): `{"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}`. A room closing sends
|
||||
`participants: []` and stops watching it. A session closing stops its watching.
|
||||
|
||||
### The proxy's part (phase 2)
|
||||
|
||||
- Room cards and lists: `privacy`, `image`, `score`/`ratings` from the room views; people in a room by name from
|
||||
`participants` (`name`), or `GET .../participants` for a room it isn't in or watching.
|
||||
- `room_occupancy/<owner>-<2100000000+n>` queues: from `room.watch`/`room.occupancy` instead of polling.
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "hosted_room_ban" (
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"by_imvu_id" bigint NOT NULL,
|
||||
"until" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_favorite" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_image" (
|
||||
"n" integer PRIMARY KEY NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"data" "bytea" NOT NULL,
|
||||
"hash" text NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_rating" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"score" integer NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_visit" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,13 @@
|
||||
"when": 1793357692630,
|
||||
"tag": "0006_decorations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1793364727935,
|
||||
"tag": "0007_hosted_rooms_phase2",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { DecorationItem, FileEntry, ParentInfo, SceneItem } from "./schema";
|
||||
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -356,12 +357,99 @@ export const hostedRoom = pgTable(
|
||||
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
|
||||
sceneHash: text("scene_hash").notNull().default(""),
|
||||
closed: boolean("closed").notNull().default(false),
|
||||
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
|
||||
privacy: text("privacy").notNull().default("public"),
|
||||
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
|
||||
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
|
||||
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
|
||||
imageHash: text("image_hash"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
|
||||
export type RoomModerator = { imvuId: number; username: string };
|
||||
|
||||
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
|
||||
dataType: () => "bytea",
|
||||
fromDriver: (v) => Buffer.from(v),
|
||||
});
|
||||
|
||||
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
|
||||
export const hostedRoomImage = pgTable("hosted_room_image", {
|
||||
n: integer("n")
|
||||
.primaryKey()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
|
||||
data: bytea("data").notNull(),
|
||||
hash: text("hash").notNull(), // sha256, hex
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
// IMVU accounts banned from a hosted room; until null: for good.
|
||||
export const hostedRoomBan = pgTable(
|
||||
"hosted_room_ban",
|
||||
{
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
|
||||
until: timestamp("until", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
|
||||
);
|
||||
|
||||
// A person's favorite hosted rooms.
|
||||
export const hostedRoomFavorite = pgTable(
|
||||
"hosted_room_favorite",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] })],
|
||||
);
|
||||
|
||||
// A person's rating of a hosted room, 1-5.
|
||||
export const hostedRoomRating = pgTable(
|
||||
"hosted_room_rating",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
score: integer("score").notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
|
||||
);
|
||||
|
||||
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
|
||||
export const hostedRoomVisit = pgTable(
|
||||
"hosted_room_visit",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
|
||||
);
|
||||
|
||||
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
|
||||
|
||||
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
|
||||
|
||||
Reference in new issue
Block a user