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]>
716 lines
33 KiB
TypeScript
716 lines
33 KiB
TypeScript
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
|
|
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
|
|
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
|
|
//
|
|
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
|
// a bearer token):
|
|
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
|
// proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now
|
|
// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the
|
|
// IMVU room instance, for a room's chat)
|
|
// proxy -> hub {"type": "leave", "chat": "1379947638"}
|
|
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
|
|
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
|
|
// products only go to clients whose IMVU account counts as adult.
|
|
// hub -> proxy {"type": "decor", "room": "1-2"}
|
|
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
|
|
// with a chat in that room
|
|
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
|
//
|
|
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
|
|
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
|
|
// proxy -> hub {"type": "room.leave", "room": n}
|
|
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
|
|
// 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", "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": "..."}
|
|
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
|
|
// 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;
|
|
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; 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. 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;
|
|
requested: number;
|
|
adult: boolean;
|
|
since: Date;
|
|
outfit: Item[];
|
|
chats: Map<string, string>;
|
|
lastSent: Map<string, string>;
|
|
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.
|
|
// 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. */
|
|
owner: (imvuId: number) => Promise<string | undefined>;
|
|
/** Whether a person's IMVU account counts as adult (the age rule). */
|
|
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
|
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
|
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;
|
|
const chatPattern = /^[0-9]{1,20}$/;
|
|
const roomPattern = /^[0-9]{1,12}-[0-9]{1,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.",
|
|
not_found: "No such room.",
|
|
closed: "This room is closed.",
|
|
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,
|
|
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. */
|
|
subscribe(fn: () => void): () => void {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
}
|
|
|
|
private changed(chats: Iterable<string> = []) {
|
|
for (const chat of new Set(chats)) this.sync(chat);
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
|
|
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
|
|
online(): Online[] {
|
|
const byId = new Map<number, Online>();
|
|
for (const s of this.sessions.values()) {
|
|
if (!s.imvuId) continue;
|
|
const prev = byId.get(s.imvuId);
|
|
if (prev && prev.since > s.since) continue;
|
|
const rooms = [...s.chats.values()].filter(Boolean);
|
|
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
|
|
}
|
|
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
|
|
}
|
|
|
|
connections(): number {
|
|
return this.sessions.size;
|
|
}
|
|
|
|
open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
|
|
this.sessions.set(id, {
|
|
userId,
|
|
imvuId: 0,
|
|
requested: 0,
|
|
adult: false,
|
|
since: new Date(),
|
|
outfit: [],
|
|
chats: new Map(),
|
|
lastSent: new Map(),
|
|
hosted: new Set(),
|
|
tokens: sendBurst,
|
|
refilled: Date.now(),
|
|
watching: new Set(),
|
|
watchGen: 0,
|
|
invites: inviteBurst,
|
|
invitesRefilled: Date.now(),
|
|
send,
|
|
close,
|
|
});
|
|
}
|
|
|
|
close(id: string) {
|
|
const s = this.sessions.get(id);
|
|
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());
|
|
}
|
|
|
|
/** A person just linked an IMVU account: their clients already using it count now. */
|
|
async linked(userId: string, imvuId: number) {
|
|
const chats: string[] = [];
|
|
let changed = false;
|
|
for (const s of this.sessions.values()) {
|
|
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
|
|
s.imvuId = imvuId;
|
|
s.since = new Date();
|
|
s.adult = await this.deps.adult(userId, imvuId);
|
|
chats.push(...s.chats.keys());
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) this.changed(chats);
|
|
}
|
|
|
|
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
|
|
unlinked(userId: string, imvuId: number) {
|
|
const chats: string[] = [];
|
|
let changed = false;
|
|
for (const [id, s] of this.sessions) {
|
|
if (s.userId === userId && s.imvuId === imvuId) {
|
|
this.leaveAllHosted(id, s, true);
|
|
s.imvuId = 0;
|
|
s.adult = false;
|
|
s.outfit = [];
|
|
chats.push(...s.chats.keys());
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) this.changed(chats);
|
|
}
|
|
|
|
/** Sends a message to all of a person's connections. */
|
|
notify(userId: string, message: object) {
|
|
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
|
|
}
|
|
|
|
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
|
|
decorated(instance: string) {
|
|
const message = { type: "decor", room: instance };
|
|
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
|
|
}
|
|
|
|
/** Closes a person's connections (banned, or signed out of their computers). */
|
|
disconnect(userId: string) {
|
|
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
|
}
|
|
|
|
/** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */
|
|
private sync(chat: string) {
|
|
const members = [...this.sessions.values()].filter((s) => s.chats.has(chat));
|
|
for (const viewer of members) {
|
|
const avatars = new Map<number, Item[]>();
|
|
for (const other of members) {
|
|
if (!other.imvuId || other.imvuId === viewer.imvuId) continue;
|
|
avatars.set(
|
|
other.imvuId,
|
|
other.outfit.filter((it) => !it.adult || viewer.adult),
|
|
);
|
|
}
|
|
const message = {
|
|
type: "chat",
|
|
chat,
|
|
avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })),
|
|
};
|
|
const text = JSON.stringify(message);
|
|
if (viewer.lastSent.get(chat) === text) continue;
|
|
viewer.lastSent.set(chat, text);
|
|
viewer.send(message);
|
|
}
|
|
}
|
|
|
|
// ---- Hosted rooms ----
|
|
|
|
/** How many people are in a hosted room now (IMVU accounts, each once). */
|
|
occupancy(n: number): number {
|
|
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 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) {
|
|
for (const id of this.hosted.get(n)?.members.keys() ?? []) if (id !== except) this.sessions.get(id)?.send(message);
|
|
}
|
|
|
|
private participantsChanged(n: number, except?: string) {
|
|
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, 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, ...(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);
|
|
for (const id of room.members.keys()) {
|
|
const s = this.sessions.get(id);
|
|
s?.hosted.delete(n);
|
|
s?.send({ type: "room.closed", room: n });
|
|
}
|
|
}
|
|
|
|
/** A hosted room's settings changed: if it became adult, those who aren't leave. (A lower maxUsers only stops
|
|
* more from coming in.) */
|
|
hostedChanged(n: number, info: HostedRoomInfo) {
|
|
if (info.closed) return this.hostedClosed(n);
|
|
if (!info.adult) return;
|
|
for (const id of [...(this.hosted.get(n)?.members.keys() ?? [])]) {
|
|
const s = this.sessions.get(id);
|
|
if (s && !s.adult) this.leaveHosted(id, s, n, true);
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
const info = await this.deps.hostedRoom(n);
|
|
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, 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();
|
|
s.tokens = Math.min(sendBurst, s.tokens + ((now - s.refilled) / 1000) * sendPerSecond);
|
|
s.refilled = now;
|
|
if (s.tokens < 1) return false;
|
|
s.tokens -= 1;
|
|
return true;
|
|
}
|
|
|
|
private sendHosted(id: string, s: Session, n: number, mount: unknown, message: unknown): object | undefined {
|
|
const room = this.hosted.get(n);
|
|
const me = room?.members.get(id);
|
|
if (!room || !me) return { type: "error", message: "not in that room" };
|
|
if (mount !== "messages" && mount !== "control") return { type: "error", message: "bad mount" };
|
|
if (typeof message !== "string" || Buffer.byteLength(message) > maxRoomMessage)
|
|
return { type: "error", message: "bad message" };
|
|
if (mount === "messages") {
|
|
// The client flags a message whose userId isn't its sender as forged; the hub doesn't pass those on at all.
|
|
const parsed = safeJSON(message) as { userId?: unknown } | null | undefined;
|
|
if (!parsed || typeof parsed !== "object" || String(parsed.userId) !== String(me.imvuId)) return;
|
|
}
|
|
if (!this.allowSend(s)) return;
|
|
room.sequence++;
|
|
this.toRoom(n, { type: "room.message", room: n, from: me.imvuId, mount, message, sequence: room.sequence }, id);
|
|
}
|
|
|
|
/** Handles a message from a proxy; returns the reply to send, if any. */
|
|
async message(id: string, raw: unknown): Promise<object | undefined> {
|
|
const s = this.sessions.get(id);
|
|
if (!s) return;
|
|
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as {
|
|
type?: unknown;
|
|
imvu_id?: unknown;
|
|
items?: unknown;
|
|
chat?: unknown;
|
|
room?: unknown;
|
|
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") {
|
|
const imvuId = Number(m.imvu_id);
|
|
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
|
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
|
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
|
|
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
|
|
if (owner !== s.userId) {
|
|
// Not this person's account: no presence, no outfit for it.
|
|
s.imvuId = 0;
|
|
s.adult = false;
|
|
s.outfit = [];
|
|
this.changed(s.chats.keys());
|
|
return { type: "error", message: "not_linked" };
|
|
}
|
|
s.imvuId = imvuId;
|
|
s.since = new Date();
|
|
s.outfit = [];
|
|
s.adult = await this.deps.adult(s.userId, imvuId);
|
|
this.changed(s.chats.keys());
|
|
return;
|
|
}
|
|
if (m.type === "outfit") {
|
|
const items = m.items ?? [];
|
|
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
|
|
if (items.length > 64) return { type: "error", message: "too many items" };
|
|
s.outfit = items
|
|
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
|
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
|
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
|
this.changed(s.chats.keys());
|
|
return;
|
|
}
|
|
if (m.type === "join" || m.type === "leave") {
|
|
const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? "");
|
|
if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" };
|
|
if (m.type === "leave") {
|
|
if (!s.chats.delete(chat)) return;
|
|
s.lastSent.delete(chat);
|
|
this.changed([chat]);
|
|
return;
|
|
}
|
|
const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : "";
|
|
if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" };
|
|
s.chats.set(chat, room);
|
|
s.lastSent.delete(chat); // tell it again, even if nothing changed
|
|
if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId);
|
|
if (room) this.deps.room(room);
|
|
this.changed([chat]);
|
|
return;
|
|
}
|
|
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, 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" };
|
|
const member = this.hosted.get(n)?.members.get(id);
|
|
if (!member) return { type: "error", message: "not in that room" };
|
|
if (member.seat !== seat) {
|
|
member.seat = seat;
|
|
this.participantsChanged(n);
|
|
}
|
|
return;
|
|
}
|
|
return { type: "error", message: "unknown message type" };
|
|
}
|
|
}
|
|
|
|
function safeJSON(s: string): unknown {
|
|
try {
|
|
return JSON.parse(s);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|