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
+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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user