diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0db2def..ff9de58 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ 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"; const { user, session, imvuLink, linkChallenge, audit } = schema; @@ -78,6 +79,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const products = new Products(db, signer, imvu); const rooms = roomsService ?? new Rooms(db, imvu); + const hosted = new HostedRooms(db); const realtime = rt ?? new Realtime({ @@ -89,6 +91,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms room: (instanceId) => { rooms.seen(instanceId).catch((e) => console.error("rooms:", e)); }, + hostedRoom: (n) => hosted.info(n), }); const limiter = new Limiter(); @@ -210,6 +213,41 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms createdAt: c.createdAt.toISOString(), }); + /** Runs a hosted-room operation, turning its errors into Problems. */ + const hostedCall = async (f: () => Promise): Promise => { + try { + return await f(); + } catch (e) { + if (e instanceof HostedError) throw new Problem(e.status, e.slug, e.message); + throw e; + } + }; + + const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({ + n: r.n, + instanceId: instanceId(r.n), + chatId: chatId(r.n), + name: r.name, + description: r.description, + roomPid: r.roomPid, + rating: r.rating as "general" | "adult", + maxUsers: r.maxUsers, + ownerImvuId: r.ownerImvuId, + ownerName, + occupancy: realtime.occupancy(r.n), + createdAt: r.createdAt.toISOString(), + }); + + 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 roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }); + const maxUsers = t.Integer({ minimum: 2, maximum: 30 }); + const sceneItem = t.Tuple([ + t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ pattern: "^[a-z2-7]{10}$" })]), + t.String({ maxLength: maxPlacement }), + ]); + const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) }); const ownLink = async (p: Person, imvuId: number) => { @@ -565,6 +603,92 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms return { rooms: out }; }) + // T5DE-hosted rooms (docs/hosted-rooms.md). + .post( + "/hosted-rooms", + async ({ me, body, set }) => { + 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 }); + set.status = 201; + return { room: hostedView(r) }; + }, + { + signedIn: true, + body: t.Object({ + name: t.String({ maxLength: 64 }), + description: t.Optional(t.String({ maxLength: 512 })), + roomPid, + rating, + maxUsers: t.Optional(maxUsers), + imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), + }), + }, + ) + .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) }; + }, + { 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 } }; + }, + { 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) }; + }, + { + signedIn: true, + params: hostedParam, + body: t.Object({ + name: t.Optional(t.String({ maxLength: 64 })), + description: t.Optional(t.String({ maxLength: 512 })), + rating: t.Optional(rating), + maxUsers: t.Optional(maxUsers), + roomPid: t.Optional(roomPid), + }), + }, + ) + .put( + "/hosted-rooms/:n/scene", + async ({ me, params, body }) => { + if (!limiter.allow(`scene:${me.id}`, 60, 60 * 1000)) + throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment."); + const r = await hostedCall(() => hosted.setScene(me.id, params.n, body.contents, body.roomPid)); + return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } }; + }, + { + signedIn: true, + params: hostedParam, + body: t.Object({ contents: t.Array(sceneItem, { maxItems: maxSceneItems }), roomPid: t.Optional(roomPid) }), + }, + ) + .delete( + "/hosted-rooms/:n", + async ({ me, params, set }) => { + const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n)); + realtime.hostedClosed(r.n); + await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) }); + set.status = 204; + }, + { signedIn: true, params: hostedParam }, + ) + // Catalogs: registering one, and the directory. .post( "/catalogs", @@ -1015,7 +1139,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms .get("/health", () => ({ ok: true })) .use(api); - return { app, realtime, age, rooms, products, signer }; + return { app, realtime, age, rooms, products, signer, hosted }; } export type App = ReturnType["app"]; diff --git a/apps/api/src/hosted.ts b/apps/api/src/hosted.ts new file mode 100644 index 0000000..92a0961 --- /dev/null +++ b/apps/api/src/hosted.ts @@ -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>; + +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 { + 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; + } +} diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index e37bc9f..58a1f60 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -1,6 +1,6 @@ // The live connection with signed-in proxies: which IMVU account each client is using (presence), the community -// products it wears, and the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's -// community products). +// 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): @@ -13,13 +13,40 @@ // 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": "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"}], +// "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) 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 }; + +/** 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 }; + // 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. type Session = { userId: string; imvuId: number; @@ -29,10 +56,17 @@ type Session = { outfit: Item[]; chats: Map; lastSent: Map; + hosted: Set; + tokens: number; + refilled: 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; sequence: number }; + export type RealtimeDeps = { /** The person an IMVU account is linked to. */ owner: (imvuId: number) => Promise; @@ -40,20 +74,37 @@ export type RealtimeDeps = { adult?: (userId: string, imvuId: number) => Promise; /** 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; }; 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 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.", +} as const; + export class Realtime { private sessions = new Map(); + private hosted = new Map(); private listeners = new Set<() => void>(); private readonly deps: Required; constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) { const d = typeof deps === "function" ? { owner: deps } : deps; - this.deps = { adult: async () => false, room: () => {}, ...d }; + this.deps = { adult: async () => false, room: () => {}, hostedRoom: async () => undefined, ...d }; } /** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */ @@ -94,6 +145,9 @@ export class Realtime { outfit: [], chats: new Map(), lastSent: new Map(), + hosted: new Set(), + tokens: sendBurst, + refilled: Date.now(), send, close, }); @@ -101,6 +155,7 @@ export class Realtime { close(id: string) { const s = this.sessions.get(id); + if (s) this.leaveAllHosted(id, s, false); this.sessions.delete(id); if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys()); } @@ -125,8 +180,9 @@ export class Realtime { unlinked(userId: string, imvuId: number) { const chats: string[] = []; let changed = false; - for (const s of this.sessions.values()) { + 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 = []; @@ -171,6 +227,115 @@ export class Realtime { } } + // ---- 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. */ + private participants(n: number): Participant[] { + const seats = new Map(); + 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 })); + } + + 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) { + this.toRoom(n, { type: "room.participants", room: n, participants: this.participants(n) }, except); + } + + /** 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) { + s.hosted.delete(n); + if (!this.hosted.get(n)?.members.delete(id)) return; + if (tell) s.send({ type: "room.closed", room: n }); + this.participantsChanged(n); + } + + 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 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); + } + } + + private async joinHosted(id: string, s: Session, n: number): Promise { + 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); + // 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"); + let room = this.hosted.get(n); + if (!room?.members.has(id)) { + 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 }); + s.hosted.add(n); + this.participantsChanged(n, id); // the newcomer's list comes in room.joined + } + return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence }; + } + + /** 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 { const s = this.sessions.get(id); @@ -181,12 +346,17 @@ export class Realtime { items?: unknown; chat?: unknown; room?: unknown; + seat?: unknown; + mount?: unknown; + message?: 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; @@ -231,6 +401,22 @@ export class Realtime { this.changed([chat]); return; } + if (m.type === "room.join" || m.type === "room.leave" || m.type === "room.seat" || m.type === "room.send") { + 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.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" }; } } diff --git a/apps/api/test/hosted.test.ts b/apps/api/test/hosted.test.ts new file mode 100644 index 0000000..a81c0b7 --- /dev/null +++ b/apps/api/test/hosted.test.ts @@ -0,0 +1,322 @@ +// 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 { createApp } from "../src/app"; +import { createAuth } from "../src/auth"; +import { ImvuClient, type ImvuUser } from "../src/imvu"; + +const webURL = "http://web.test"; +const accounts = new Map(); + +const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const u = [...accounts.values()].find( + (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), + ); + return Response.json({ + denormalized: u + ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } + : {}, + }); +}) as typeof fetch; + +let db: Database; +let close: () => Promise; +let base: string; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); + const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) }); + hub.app.listen({ port: 0, hostname: "127.0.0.1" }); + const server = hub.app.server as unknown as { port: number; stop: () => void }; + stop = () => server.stop(); + base = `http://127.0.0.1:${server.port}`; +}); + +afterAll(async () => { + stop(); + await close(); +}); + +type Person = { cookie: string; token: string }; + +async function call(p: Person | undefined, method: string, path: string, body?: unknown) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : undefined }; +} + +/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */ +async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise { + const res = await fetch(base + "/api/auth/sign-up/email", { + method: "POST", + headers: { Origin: webURL, "Content-Type": "application/json" }, + body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }), + }); + const p = { + cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), + token: res.headers.get("set-auth-token") ?? "", + }; + expect(p.token).not.toBe(""); + for (const [i, a] of imvu.entries()) { + const username = `${name}Av${i || ""}`; + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + const started = await call(p, "POST", "/api/v1/account/links", { username }); + accounts.get(a.id)!.tagline = started.json.challenge.code; + expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); + } + return p; +} + +/** A proxy's live connection, signed in to the client as imvuId (0: none). */ +async function proxy(p: Person, imvuId: number) { + const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any); + const got: any[] = []; + ws.onmessage = (e) => got.push(JSON.parse(String(e.data))); + await new Promise((r) => (ws.onopen = r)); + const send = (m: object) => ws.send(JSON.stringify(m)); + if (imvuId) { + send({ type: "session", imvu_id: imvuId }); + await Bun.sleep(30); + } + const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1); + /** Sends a message and waits for the answer of that type for that room. */ + const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => { + const before = got.length; + send(m); + await waitFor(() => got.slice(before).some(answer)); + return got.slice(before).find(answer); + }; + 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 }; +} + +async function waitFor(ok: () => boolean) { + for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10); + expect(ok()).toBe(true); +} + +const newRoom = (imvuId: number, over: Record = {}) => ({ + name: "Lounge", + description: "A quiet place.", + roomPid: 1234, + rating: "general", + maxUsers: 10, + imvuId, + ...over, +}); + +test("rooms are made, listed, changed and closed by their owners", async () => { + const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 }); + const ben = await person("benn", { id: 311 }); + + // Validation and permissions. + expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid"); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400); + expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked"); + expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult"); + + // Made: owned by the chosen account, shown with its IMVU name. + const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined })); + expect(made.status).toBe(201); + const n = made.json.room.n; + expect(made.json.room).toMatchObject({ + instanceId: `2100000000-${n}`, + chatId: 2_100_000_000 + n, + name: "The Lounge", + description: "A quiet place.", + roomPid: 1234, + rating: "general", + maxUsers: 10, + ownerImvuId: 302, + ownerName: "adamAv1", + occupancy: 0, + }); + const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room; + expect(adult.rating).toBe("adult"); + + // Listed newest first; adult rooms only for adults. + const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name); + expect(await names(ada)).toEqual(["Late", "The Lounge"]); + expect(await names(ben)).toEqual(["The Lounge"]); + expect(await names()).toEqual(["The Lounge"]); + expect(await names(ben, "?mine=1")).toEqual([]); + expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]); + expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401); + + // One room, with its scene. + const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`); + expect(got.json.scene.contents).toEqual([]); + expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/); + expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404); + expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404); + expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400); + + // Changes: owner only. + expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403); + expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400); + const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" }); + expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 }); + + // The scene: owner only, checked. + const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] }; + expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403); + expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400); + expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400); + expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400); + const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) }; + expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400); + const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 }); + expect(put.status).toBe(200); + expect(put.json.scene.contents).toEqual(scene.contents); + expect(put.json.scene.hash).not.toBe(got.json.scene.hash); + const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`); + expect(after.json.scene).toEqual(put.json.scene); + expect(after.json.room.roomPid).toBe(4321); + + // Closing: owner only; then it's gone from lists and answers 410. + expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403); + expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204); + expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed"); + expect(await names(ada)).toEqual(["Late"]); + expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410); + + // Unlinking the owner account hides its rooms. + await call(ada, "DELETE", "/api/v1/account/links/301"); + expect(await names(ada)).toEqual([]); +}); + +test("a person may own up to 10 open rooms", async () => { + const cy = await person("cyd", { id: 321 }); + const ns: number[] = []; + for (let i = 0; i < 10; i++) { + const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` })); + expect(r.status).toBe(201); + ns.push(r.json.room.n); + } + expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap"); + await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`); + expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201); +}); + +test("joining, seats and messages in a hosted room", async () => { + const dee = await person("deen", { id: 401, ageVerified: true }); + const eli = await person("elia", { id: 402 }); + const fay = await person("faye", { id: 403 }); + const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room; + const n: number = room.n; + const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n; + + const d = await proxy(dee, 401); + const e = await proxy(eli, 402); + const f = await proxy(fay, 403); + + // Refusals: no linked account in use, no such room, adult room for a non-adult. + const nobody = await proxy(eli, 0); + expect((await nobody.join(n)).reason).toBe("not_linked"); + nobody.ws.close(); + const notMine = await proxy(eli, 403); // Fay's account, not Eli's + expect((await notMine.join(n)).reason).toBe("not_linked"); + notMine.ws.close(); + expect((await e.join(999_999)).reason).toBe("not_found"); + expect((await e.join(adultRoom)).reason).toBe("adult"); + e.send({ type: "room.join", room: 0 }); + await waitFor(() => e.got.some((m) => m.message === "bad room")); + + // 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 }); + const ej = await e.join(n); + expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]); + 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); + + // Full at two people; joining again (a reconnect) answers the same. + expect((await f.join(n)).reason).toBe("full"); + expect((await e.join(n)).type).toBe("room.joined"); + + // Seats. + e.send({ type: "room.seat", room: n, seat: 3 }); + await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3)); + e.send({ type: "room.seat", room: n, seat: 100 }); + await waitFor(() => e.got.some((m) => m.message === "bad seat")); + + // Messages go to the others, numbered; forged senders are dropped. + const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 }); + e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") }); + e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") }); + e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" }); + e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) }); + await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2); + await Bun.sleep(50); + const msgs = d.got.filter((m) => m.type === "room.message"); + expect(msgs).toEqual([ + { type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 }, + { type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 }, + ]); + expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender + e.send({ type: "room.send", room: n, mount: "other", message: "x" }); + await waitFor(() => e.got.some((m) => m.message === "bad mount")); + e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) }); + await waitFor(() => e.got.some((m) => m.message === "bad message")); + f.send({ type: "room.send", room: n, mount: "control", message: "{}" }); + await waitFor(() => f.got.some((m) => m.message === "not in that room")); + + // Rate: a burst of 20, then the rest is dropped. + for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` }); + await Bun.sleep(150); + const burst = d.got.filter((m) => m.type === "room.message").length - 2; + expect(burst).toBeGreaterThanOrEqual(18); + expect(burst).toBeLessThanOrEqual(23); + expect((await d.join(n)).sequence).toBe(2 + burst); + + // Leaving makes room; a closed connection leaves too. + e.send({ type: "room.leave", room: n }); + await waitFor(() => d.last("room.participants", n)?.participants.length === 1); + expect((await f.join(n)).type).toBe("room.joined"); + f.ws.close(); + await waitFor(() => d.last("room.participants", n)?.participants.length === 1); + + // Closing tells everyone in it; joining it again is refused. + expect((await e.join(n)).type).toBe("room.joined"); + expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204); + await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined); + expect((await e.join(n)).reason).toBe("closed"); + d.ws.close(); + e.ws.close(); +}); + +test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => { + const gil = await person("gilt", { id: 501, ageVerified: true }); + const hal = await person("hale", { id: 502 }); + const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n; + const g = await proxy(gil, 501); + const h = await proxy(hal, 502); + await g.join(n); + await h.join(n); + expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200); + await waitFor(() => h.last("room.closed", n) !== undefined); + await waitFor(() => g.last("room.participants", n)?.participants.length === 1); + expect(g.last("room.closed", n)).toBeUndefined(); + + await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" }); + await h.join(n); + await waitFor(() => g.last("room.participants", n)?.participants.length === 2); + await call(hal, "DELETE", "/api/v1/account/links/502"); + await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2); + await waitFor(() => g.last("room.participants", n)?.participants.length === 1); + g.ws.close(); + h.ws.close(); +}); diff --git a/apps/web/app/rooms/[n]/edit/page.tsx b/apps/web/app/rooms/[n]/edit/page.tsx new file mode 100644 index 0000000..4ac53b4 --- /dev/null +++ b/apps/web/app/rooms/[n]/edit/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +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 { RoomFields } from "../../room-fields"; + +export const metadata: Metadata = { title: "Edit room" }; + +export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) { + const { n } = await params; + if (!/^[0-9]{1,9}$/.test(n)) notFound(); + const me = await getMe(); + if (!me) redirect(`/signin?next=/rooms/${n}/edit`); + const [{ rooms }, { links }] = await Promise.all([ + api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap), + api.account.get().then(unwrap), + ]); + const room = rooms.find((r) => String(r.n) === n); + if (!room) notFound(); + + return ( +
+ + + Back to rooms + + + + + + + Owned by {room.ownerName} · {room.occupancy} here now + + + + + + l.adult)} /> +
+ Save +
+
+
+
+
+ + + + Close room + Everyone in it is sent out. It can't be opened again. + + + + + Close room + + + + +
+ ); +} diff --git a/apps/web/app/rooms/actions.ts b/apps/web/app/rooms/actions.ts new file mode 100644 index 0000000..7c4b468 --- /dev/null +++ b/apps/web/app/rooms/actions.ts @@ -0,0 +1,56 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +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)); + +/** Makes a hosted room, then goes back to the list. */ +export async function createRoom(_: ActionState, form: FormData): Promise { + const state = await attempt(async () => { + unwrap( + await api["hosted-rooms"].post({ + name: field(form, "name"), + description: field(form, "description"), + roomPid: Number(field(form, "roomPid")), + rating: rating(form), + maxUsers: Number(field(form, "maxUsers")), + imvuId: Number(field(form, "imvuId")), + }), + ); + revalidatePath("/rooms"); + }); + if (state?.error) return state; + redirect("/rooms"); +} + +/** The owner's changes to a room. */ +export async function saveRoom(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + const state = await attempt(async () => { + unwrap( + await api["hosted-rooms"]({ n }).patch({ + name: field(form, "name"), + description: field(form, "description"), + roomPid: Number(field(form, "roomPid")), + rating: rating(form), + maxUsers: Number(field(form, "maxUsers")), + }), + ); + revalidatePath("/rooms"); + }); + if (state?.error) return state; + redirect("/rooms"); +} + +/** Closes a room: everyone in it is sent out. */ +export async function closeRoom(_: ActionState, form: FormData): Promise { + const n = field(form, "n"); + const state = await attempt(async () => { + unwrap(await api["hosted-rooms"]({ n }).delete()); + revalidatePath("/rooms"); + }); + if (state?.error) return state; + redirect("/rooms"); +} diff --git a/apps/web/app/rooms/new/page.tsx b/apps/web/app/rooms/new/page.tsx new file mode 100644 index 0000000..3ae0f17 --- /dev/null +++ b/apps/web/app/rooms/new/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader } from "@/components/status"; +import { Card, CardContent } from "@/components/ui/card"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { api, getMe, unwrap } from "@/lib/api"; +import { createRoom } from "../actions"; +import { RoomFields } from "../room-fields"; + +export const metadata: Metadata = { title: "New room" }; + +export default async function NewRoom() { + const me = await getMe(); + if (!me) redirect("/signin?next=/rooms/new"); + const { links } = await api.account.get().then(unwrap); + + return ( +
+ + + Back to rooms + + + + + {links.length === 0 ? ( +

+ + Link an IMVU account + {" "} + first: it's shown as the room's owner. +

+ ) : ( + + + l.adult)} /> + + Owner + + {links.map((l) => ( + + {l.username} + + ))} + + The IMVU account shown as owner. It can decorate the room. + +
+ Make room +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/rooms/page.tsx b/apps/web/app/rooms/page.tsx index 1b3af5a..8702add 100644 --- a/apps/web/app/rooms/page.tsx +++ b/apps/web/app/rooms/page.tsx @@ -1,55 +1,121 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { PageHeader, Tag } from "@/components/status"; +import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { api, unwrap } from "@/lib/api"; +import { api, getMe, unwrap, type HostedRoom } from "@/lib/api"; export const metadata: Metadata = { title: "Rooms" }; export default async function Rooms() { - const { rooms } = await api.rooms.get().then(unwrap); + const me = await getMe(); + const [{ rooms }, { rooms: hosted }, mine] = await Promise.all([ + api.rooms.get().then(unwrap), + api["hosted-rooms"].get({ query: {} }).then(unwrap), + me ? api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap) : { rooms: [] as HostedRoom[] }, + ]); + const own = new Set(mine.rooms.map((r) => r.n)); + return ( - <> - Public IMVU rooms of T5DE users. - {rooms.length === 0 ? ( - - -

No rooms listed yet.

-
-
- ) : ( -
    - {rooms.map((r) => ( -
  • - - {r.imageURL && ( - // IMVU's own image of the room, from IMVU. - - )} - -
    - - {r.name} - - {r.adult && AP} - {r.t5de.length > 0 && ( - - {r.t5de.length} on T5DE - - )} -
    -

    - {r.owner.username} · {r.occupancy}/{r.capacity} -

    - {r.description &&

    {r.description}

    } - {r.t5de.length > 0 && ( -

    Here now: {r.t5de.map((u) => u.username).join(", ")}

    +
    +
    + Rooms on T5DE, and public IMVU rooms of T5DE users. + {me && ( + + New room + + )} +
    + +
    +

    On T5DE

    + {hosted.length === 0 ? ( + + +

    No rooms yet.

    +
    +
    + ) : ( +
      + {hosted.map((r) => ( +
    • + +
    • + ))} +
    + )} +
    + +
    +

    On IMVU

    + {rooms.length === 0 ? ( + + +

    No rooms listed yet.

    +
    +
    + ) : ( +
      + {rooms.map((r) => ( +
    • + + {r.imageURL && ( + // IMVU's own image of the room, from IMVU. + )} - - -
    • - ))} -
    - )} - + +
    + + {r.name} + + {r.adult && AP} + {r.t5de.length > 0 && ( + + {r.t5de.length} on T5DE + + )} +
    +

    + {r.owner.username} · {r.occupancy}/{r.capacity} +

    + {r.description &&

    {r.description}

    } + {r.t5de.length > 0 && ( +

    Here now: {r.t5de.map((u) => u.username).join(", ")}

    + )} +
    + +
  • + ))} +
+ )} + + + ); +} + +function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) { + return ( + + +
+ {r.name} + {r.rating === "adult" && Adult} + {r.occupancy > 0 && ( + + {r.occupancy} here + + )} + {mine && ( + + Edit + + )} +
+

+ {r.ownerName} · {r.occupancy}/{r.maxUsers} +

+ {r.description &&

{r.description}

} +
+
); } diff --git a/apps/web/app/rooms/room-fields.tsx b/apps/web/app/rooms/room-fields.tsx new file mode 100644 index 0000000..0c4da6a --- /dev/null +++ b/apps/web/app/rooms/room-fields.tsx @@ -0,0 +1,60 @@ +// The fields of a hosted room, for making one and for editing it. +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +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 }) { + return ( + <> + + Name + + + + Description +