Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
CI / test (push) Successful in 1m10s
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot and ban over realtime (room.boot); bans are listed and lifted over REST. Private rooms are unlisted and joined by the owner, moderators and holders of an accepted invite (room.invite / room.invited / room.invite_reply / room.invite_answer). Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database and served with cache headers. Per-person favorites, ratings and recent rooms. room.watch pushes room.occupancy; participants carry their IMVU name, and GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2". Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
43183ec7ee
commit
76c8e0342e
16 files changed
+3851
-73
No files matched your search
+294
-14
@@ -2,7 +2,21 @@
|
||||
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
|
||||
// token.
|
||||
import { Elysia, t } from "elysia";
|
||||
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
count,
|
||||
schema,
|
||||
type Database,
|
||||
type ParentInfo,
|
||||
type RoomModerator,
|
||||
} from "@t5de/db";
|
||||
import type { Auth } from "./auth";
|
||||
import { AgeError, AgeService } from "./age";
|
||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||
@@ -11,7 +25,19 @@ import { Realtime } from "./realtime";
|
||||
import { isAdultRoom, Rooms } from "./rooms";
|
||||
import { Signer } from "./keys";
|
||||
import { ProductError, Products } from "./products";
|
||||
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
|
||||
import {
|
||||
chatId,
|
||||
HostedError,
|
||||
HostedRooms,
|
||||
instanceId,
|
||||
maxImageBytes,
|
||||
maxModerators,
|
||||
maxPlacement,
|
||||
maxSceneItems,
|
||||
roomInfo,
|
||||
type RoomChanges,
|
||||
type Viewer,
|
||||
} from "./hosted";
|
||||
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||
@@ -82,6 +108,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||
const hosted = new HostedRooms(db);
|
||||
const decorations = new Decorations(db);
|
||||
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
|
||||
const realtime =
|
||||
rt ??
|
||||
new Realtime({
|
||||
@@ -94,6 +121,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
|
||||
},
|
||||
hostedRoom: (n) => hosted.info(n),
|
||||
hostedBanned: (n, imvuId) => hosted.banned(n, imvuId),
|
||||
hostedBoot: async (b) => {
|
||||
if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes);
|
||||
await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), {
|
||||
imvu: b.imvuId,
|
||||
by: b.by,
|
||||
...(b.ban ? { minutes: b.minutes } : {}),
|
||||
});
|
||||
},
|
||||
accountName: async (imvuId) => {
|
||||
const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
||||
return l?.username ?? "";
|
||||
},
|
||||
hostedVisit: (userId, n, imvuId) => {
|
||||
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
|
||||
},
|
||||
});
|
||||
const limiter = new Limiter();
|
||||
|
||||
@@ -225,7 +268,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
}
|
||||
};
|
||||
|
||||
const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({
|
||||
type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string };
|
||||
type Extras = { favorite: boolean; score: number; ratings: number };
|
||||
|
||||
const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({
|
||||
n: r.n,
|
||||
instanceId: instanceId(r.ownerImvuId, r.n),
|
||||
chatId: chatId(r.n),
|
||||
@@ -237,11 +283,77 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
ownerImvuId: r.ownerImvuId,
|
||||
ownerName,
|
||||
occupancy: realtime.occupancy(r.n),
|
||||
privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private",
|
||||
moderators: r.moderators.map((m) => m.imvuId),
|
||||
moderatorNames: r.moderators.map((m) => m.username),
|
||||
image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null,
|
||||
favorite: extras.favorite,
|
||||
score: extras.score,
|
||||
ratings: extras.ratings,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
/** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */
|
||||
const hostedViews = async (rows: HostedRow[], person: Person | undefined) => {
|
||||
const ns = rows.map((r) => r.room.n);
|
||||
const [scores, favorites] = await Promise.all([
|
||||
hosted.scores(ns),
|
||||
person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set<number>()),
|
||||
]);
|
||||
return rows.map((r) =>
|
||||
hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }),
|
||||
);
|
||||
};
|
||||
const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!;
|
||||
|
||||
/** The person and their linked IMVU accounts. */
|
||||
const viewerOf = async (p: Person): Promise<Viewer> => ({
|
||||
id: p.id,
|
||||
imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id),
|
||||
});
|
||||
|
||||
/** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */
|
||||
const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => {
|
||||
if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`);
|
||||
const out: RoomModerator[] = [];
|
||||
for (const e of entries) {
|
||||
const text = typeof e === "string" ? e.trim() : "";
|
||||
const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0;
|
||||
if (id > 0) {
|
||||
const name = known.find((m) => m.imvuId === id)?.username;
|
||||
const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id));
|
||||
out.push({ imvuId: id, username: name ?? l?.username ?? "" });
|
||||
continue;
|
||||
}
|
||||
if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs.");
|
||||
const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase());
|
||||
if (k) {
|
||||
out.push(k);
|
||||
continue;
|
||||
}
|
||||
const [l] = await db
|
||||
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username })
|
||||
.from(imvuLink)
|
||||
.where(sql`lower(${imvuLink.username}) = lower(${text})`);
|
||||
if (l) {
|
||||
out.push(l);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const found = await imvuCall(me, () => imvu.byUsername(text));
|
||||
out.push({ imvuId: found.id, username: found.username });
|
||||
} catch (err) {
|
||||
if (err instanceof Problem && err.slug === "no_such_imvu_account")
|
||||
throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) });
|
||||
const rating = t.Union([t.Literal("general"), t.Literal("adult")]);
|
||||
const privacy = t.Union([t.Literal("public"), t.Literal("private")]);
|
||||
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
||||
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
|
||||
const sceneItem = t.Tuple([
|
||||
@@ -628,7 +740,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
|
||||
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
|
||||
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
|
||||
await audited(me.id, "hosted_room.create", String(r.room.n), { name: r.room.name, rating: r.room.rating });
|
||||
await audited(me.id, "hosted_room.create", String(r.room.n), {
|
||||
name: r.room.name,
|
||||
rating: r.room.rating,
|
||||
privacy: r.room.privacy,
|
||||
});
|
||||
set.status = 201;
|
||||
return { room: hostedView(r) };
|
||||
},
|
||||
@@ -641,34 +757,64 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
rating,
|
||||
maxUsers: t.Optional(maxUsers),
|
||||
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
privacy: t.Optional(privacy),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/hosted-rooms",
|
||||
async ({ person, query }) => {
|
||||
const mine = query.mine === "1" || query.mine === "true";
|
||||
if (mine && !person) throw new Problem(401, "signed_out", "Sign in first.");
|
||||
const rows = await hosted.list({ adult: await personAdult(person), mine: mine ? person!.id : undefined });
|
||||
return { rooms: rows.map(hostedView) };
|
||||
const on = (v?: string) => v === "1" || v === "true";
|
||||
const mine = on(query.mine);
|
||||
const favorites = on(query.favorites);
|
||||
const recent = on(query.recent);
|
||||
if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first.");
|
||||
const rows = await hosted.list({
|
||||
adult: await personAdult(person),
|
||||
viewer: person ? await viewerOf(person) : undefined,
|
||||
mine,
|
||||
favorites,
|
||||
recent,
|
||||
});
|
||||
return { rooms: await hostedViews(rows, person) };
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
mine: t.Optional(t.String({ maxLength: 8 })),
|
||||
favorites: t.Optional(t.String({ maxLength: 8 })),
|
||||
recent: t.Optional(t.String({ maxLength: 8 })),
|
||||
}),
|
||||
},
|
||||
{ query: t.Object({ mine: t.Optional(t.String({ maxLength: 8 })) }) },
|
||||
)
|
||||
.get(
|
||||
"/hosted-rooms/:n",
|
||||
async ({ person, params }) => {
|
||||
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
|
||||
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
|
||||
return {
|
||||
room: await hostedViewFor(r, person),
|
||||
scene: { contents: r.room.scene, hash: r.room.sceneHash },
|
||||
myRating: person ? await hosted.myRating(person.id, r.room.n) : 0,
|
||||
};
|
||||
},
|
||||
{ params: hostedParam },
|
||||
)
|
||||
.patch(
|
||||
"/hosted-rooms/:n",
|
||||
async ({ me, params, body }) => {
|
||||
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, body));
|
||||
realtime.hostedChanged(r.room.n, { closed: false, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers });
|
||||
await audited(me.id, "hosted_room.edit", String(r.room.n), { fields: Object.keys(body) });
|
||||
return { room: hostedView(r) };
|
||||
const { moderators: given, ...rest } = body;
|
||||
const changes: RoomChanges = given
|
||||
? {
|
||||
...rest,
|
||||
moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators),
|
||||
}
|
||||
: rest;
|
||||
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
|
||||
realtime.hostedChanged(r.room.n, roomInfo(r));
|
||||
await audited(me.id, "hosted_room.edit", String(r.room.n), {
|
||||
fields: Object.keys(body),
|
||||
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
|
||||
});
|
||||
return { room: await hostedViewFor(r, me) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
@@ -679,6 +825,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
rating: t.Optional(rating),
|
||||
maxUsers: t.Optional(maxUsers),
|
||||
roomPid: t.Optional(roomPid),
|
||||
privacy: t.Optional(privacy),
|
||||
moderators: t.Optional(
|
||||
t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), {
|
||||
maxItems: maxModerators,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -707,6 +859,134 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
|
||||
// Phase 2: bans, images, favorites, ratings.
|
||||
.get(
|
||||
"/hosted-rooms/:n/participants",
|
||||
async ({ person, params }) => {
|
||||
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
|
||||
return { participants: realtime.participants(r.room.n) };
|
||||
},
|
||||
{ params: hostedParam },
|
||||
)
|
||||
.get(
|
||||
"/hosted-rooms/:n/bans",
|
||||
async ({ me, params }) => {
|
||||
await hostedCall(async () => hosted.managed(await viewerOf(me), params.n));
|
||||
const bans = await hosted.bans(params.n);
|
||||
return {
|
||||
bans: bans.map((b) => ({
|
||||
imvuId: b.imvuId,
|
||||
byImvuId: b.byImvuId,
|
||||
until: b.until?.toISOString() ?? null,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
.delete(
|
||||
"/hosted-rooms/:n/bans/:imvu",
|
||||
async ({ me, params, set }) => {
|
||||
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
|
||||
throw new Problem(404, "not_found", "That account isn't banned here.");
|
||||
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({
|
||||
n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }),
|
||||
imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/hosted-rooms/:n/image",
|
||||
async ({ person, params, request }) => {
|
||||
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
|
||||
const img = r.room.imageHash ? await hosted.image(params.n) : undefined;
|
||||
if (!img) throw new Problem(404, "not_found", "This room has no image.");
|
||||
const etag = `"${img.hash}"`;
|
||||
const headers = {
|
||||
"Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`,
|
||||
ETag: etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
};
|
||||
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
|
||||
return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } });
|
||||
},
|
||||
{ params: hostedParam },
|
||||
)
|
||||
.put(
|
||||
"/hosted-rooms/:n/image",
|
||||
async ({ me, params, body }) => {
|
||||
if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000))
|
||||
throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while.");
|
||||
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
|
||||
const data = Buffer.from(await body.image.arrayBuffer());
|
||||
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
|
||||
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
|
||||
return { room: await hostedViewFor(r, me) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: hostedParam,
|
||||
body: t.Object({ image: t.File() }),
|
||||
beforeHandle({ request }) {
|
||||
// Refuse big uploads before reading them (with room for the multipart framing).
|
||||
if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024)
|
||||
throw new Problem(400, "invalid", "An image is up to 512 KB.");
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/hosted-rooms/:n/image",
|
||||
async ({ me, params }) => {
|
||||
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
|
||||
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
|
||||
return { room: await hostedViewFor(r, me) };
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
.put(
|
||||
"/hosted-rooms/:n/favorite",
|
||||
async ({ me, params, set }) => {
|
||||
if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
|
||||
await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
|
||||
await hosted.favorite(me.id, params.n, true);
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
.delete(
|
||||
"/hosted-rooms/:n/favorite",
|
||||
async ({ me, params, set }) => {
|
||||
await hosted.favorite(me.id, params.n, false);
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
.put(
|
||||
"/hosted-rooms/:n/rating",
|
||||
async ({ me, params, body }) => {
|
||||
if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
|
||||
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
|
||||
if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room.");
|
||||
await hosted.rate(me.id, params.n, body.score);
|
||||
return { room: await hostedViewFor(r, me), myRating: body.score };
|
||||
},
|
||||
{ signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) },
|
||||
)
|
||||
.delete(
|
||||
"/hosted-rooms/:n/rating",
|
||||
async ({ me, params, set }) => {
|
||||
await hosted.rate(me.id, params.n, null);
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
)
|
||||
|
||||
// Decorations over IMVU rooms (docs/products.md, "Decorations").
|
||||
.get(
|
||||
"/decorations",
|
||||
|
||||
Reference in new issue
Block a user