Rooms made on the website (hosted_room, migration 0005), the REST routes in docs/hosted-rooms.md, and the realtime room.* protocol: joining with the capacity, adult and linked-account rules, seats, numbered messages with forged senders dropped and a rate limit, closing. The Rooms page lists them, with a form to make one and the owner's edit and close. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0c67232425
commit
2a1fc94df6
18 files changed
+3138
-52
No files matched your search
@@ -0,0 +1,191 @@
|
||||
// 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.
|
||||
//
|
||||
// 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 { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
|
||||
|
||||
const { hostedRoom, imvuLink, user } = schema;
|
||||
|
||||
export type HostedRoom = typeof hostedRoom.$inferSelect;
|
||||
export type Rating = "general" | "adult";
|
||||
|
||||
/** An error for the person; the routes turn it into a Problem. */
|
||||
export class HostedError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly slug: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const maxRoomsPerPerson = 10;
|
||||
export const maxSceneItems = 500;
|
||||
export const maxPlacement = 200;
|
||||
|
||||
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
|
||||
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
|
||||
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
|
||||
|
||||
export const instanceId = (n: number) => `${hostedBase}-${n}`;
|
||||
export const chatId = (n: number) => hostedBase + n;
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
|
||||
|
||||
function cleanName(s: string): string {
|
||||
const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim();
|
||||
if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters.");
|
||||
return v;
|
||||
}
|
||||
|
||||
function cleanDescription(s: string): string {
|
||||
const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim();
|
||||
if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters.");
|
||||
return v;
|
||||
}
|
||||
|
||||
export type NewRoom = {
|
||||
name: string;
|
||||
description?: string;
|
||||
roomPid: number;
|
||||
rating: Rating;
|
||||
maxUsers?: number;
|
||||
imvuId: number;
|
||||
};
|
||||
|
||||
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers">>;
|
||||
|
||||
export class HostedRooms {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
// Rooms that are shown: the owner account still linked to the owner, the owner not banned.
|
||||
private shown() {
|
||||
return this.db
|
||||
.select({ room: hostedRoom, ownerName: imvuLink.username })
|
||||
.from(hostedRoom)
|
||||
.innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId)))
|
||||
.innerJoin(user, eq(user.id, hostedRoom.ownerId))
|
||||
.$dynamic();
|
||||
}
|
||||
|
||||
private visible = sql`coalesce(${user.banned}, false) = false`;
|
||||
|
||||
/** 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"),
|
||||
),
|
||||
)
|
||||
.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) {
|
||||
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
|
||||
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
|
||||
if (!r) throw new HostedError(404, "not_found", "No such room.");
|
||||
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
|
||||
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
|
||||
throw new HostedError(404, "not_found", "No such room.");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
|
||||
async info(n: number): Promise<HostedRoomInfo | undefined> {
|
||||
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
|
||||
if (!r) return undefined;
|
||||
return { closed: r.room.closed, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers };
|
||||
}
|
||||
|
||||
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
|
||||
const name = cleanName(body.name);
|
||||
const description = cleanDescription(body.description ?? "");
|
||||
if (body.rating === "adult" && !adultPerson)
|
||||
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
|
||||
const [link] = await this.db
|
||||
.select()
|
||||
.from(imvuLink)
|
||||
.where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId)));
|
||||
if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you.");
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [{ n } = { n: 0 }] = await tx
|
||||
.select({ n: count() })
|
||||
.from(hostedRoom)
|
||||
.where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false)));
|
||||
if (n >= maxRoomsPerPerson)
|
||||
throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`);
|
||||
const [r] = await tx
|
||||
.insert(hostedRoom)
|
||||
.values({
|
||||
ownerId,
|
||||
ownerImvuId: link.imvuId,
|
||||
name,
|
||||
description,
|
||||
roomPid: body.roomPid,
|
||||
rating: body.rating,
|
||||
maxUsers: body.maxUsers ?? 10,
|
||||
scene: [],
|
||||
sceneHash: sceneHash(body.roomPid, []),
|
||||
})
|
||||
.returning();
|
||||
return { room: r!, ownerName: link.username };
|
||||
});
|
||||
}
|
||||
|
||||
/** The owner's room, open (for changes). */
|
||||
private async own(ownerId: string, n: number) {
|
||||
const r = await this.get(n, true, ownerId);
|
||||
if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
|
||||
return r;
|
||||
}
|
||||
|
||||
async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) {
|
||||
const { room: r, ownerName } = await this.own(ownerId, n);
|
||||
if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson)
|
||||
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
|
||||
const roomPid = changes.roomPid ?? r.roomPid;
|
||||
const [updated] = await this.db
|
||||
.update(hostedRoom)
|
||||
.set({
|
||||
...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}),
|
||||
...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}),
|
||||
...(changes.rating !== undefined ? { rating: changes.rating } : {}),
|
||||
...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}),
|
||||
...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}),
|
||||
})
|
||||
.where(eq(hostedRoom.n, n))
|
||||
.returning();
|
||||
return { room: updated!, ownerName };
|
||||
}
|
||||
|
||||
async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) {
|
||||
const { room: r, ownerName } = await this.own(ownerId, n);
|
||||
const pid = roomPid ?? r.roomPid;
|
||||
const [updated] = await this.db
|
||||
.update(hostedRoom)
|
||||
.set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) })
|
||||
.where(eq(hostedRoom.n, n))
|
||||
.returning();
|
||||
return { room: updated!, ownerName };
|
||||
}
|
||||
|
||||
/** Closes a room: by its owner, or (moderator) anyone's. */
|
||||
async close(actorId: string, moderator: boolean, n: number) {
|
||||
const { room: r } = await this.get(n, true, actorId);
|
||||
if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
|
||||
await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n));
|
||||
return r;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user