The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which other T5DE users are there and what community products they wear (adult products only to adult accounts), on joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who show they're online. AP rooms are shown to adults only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7db9fc0370
commit
6ba4d83317
18 files changed
+1838
-50
No files matched your search
+73
-10
@@ -8,10 +8,11 @@ import { AgeError, AgeService } from "./age";
|
|||||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||||
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
||||||
import { Realtime } from "./realtime";
|
import { Realtime } from "./realtime";
|
||||||
|
import { isAdultRoom, Rooms } from "./rooms";
|
||||||
|
|
||||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||||
|
|
||||||
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService };
|
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms };
|
||||||
|
|
||||||
/** An error shown to the person: the website displays message as is. */
|
/** An error shown to the person: the website displays message as is. */
|
||||||
export class Problem extends Error {
|
export class Problem extends Error {
|
||||||
@@ -61,13 +62,20 @@ class Limiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) {
|
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) {
|
||||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||||
|
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||||
const realtime =
|
const realtime =
|
||||||
rt ??
|
rt ??
|
||||||
new Realtime(async (imvuId) => {
|
new Realtime({
|
||||||
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
owner: async (imvuId) => {
|
||||||
return l?.userId;
|
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
||||||
|
return l?.userId;
|
||||||
|
},
|
||||||
|
adult: async (userId, imvuId) => (await age.adultAccounts(userId, [imvuId])).has(imvuId),
|
||||||
|
room: (instanceId) => {
|
||||||
|
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const limiter = new Limiter();
|
const limiter = new Limiter();
|
||||||
|
|
||||||
@@ -101,6 +109,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
|
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
|
||||||
showBadge: l.showBadge,
|
showBadge: l.showBadge,
|
||||||
showPresence: l.showPresence,
|
showPresence: l.showPresence,
|
||||||
|
listRooms: l.listRooms,
|
||||||
online: live !== undefined,
|
online: live !== undefined,
|
||||||
outfit: live?.outfit ?? [],
|
outfit: live?.outfit ?? [],
|
||||||
};
|
};
|
||||||
@@ -365,8 +374,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
|
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
|
||||||
if (result.moved) await age.forgetAccount(found.id);
|
if (result.moved) await age.forgetAccount(found.id);
|
||||||
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
|
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
|
||||||
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
|
if (result.moved) {
|
||||||
realtime.linked(me.id, found.id);
|
await rooms.forgetOwner(found.id);
|
||||||
|
realtime.unlinked(result.previousOwner!, found.id);
|
||||||
|
}
|
||||||
|
await realtime.linked(me.id, found.id);
|
||||||
return { link: await linkView(result.link), moved: result.moved };
|
return { link: await linkView(result.link), moved: result.moved };
|
||||||
},
|
},
|
||||||
{ signedIn: true, params: imvuIdParam },
|
{ signedIn: true, params: imvuIdParam },
|
||||||
@@ -388,6 +400,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
.set({
|
.set({
|
||||||
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
|
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
|
||||||
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
|
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
|
||||||
|
...(body.listRooms !== undefined ? { listRooms: body.listRooms } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(imvuLink.imvuId, params.imvu))
|
.where(eq(imvuLink.imvuId, params.imvu))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -396,7 +409,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
{
|
{
|
||||||
signedIn: true,
|
signedIn: true,
|
||||||
params: imvuIdParam,
|
params: imvuIdParam,
|
||||||
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }),
|
body: t.Object({
|
||||||
|
showBadge: t.Optional(t.Boolean()),
|
||||||
|
showPresence: t.Optional(t.Boolean()),
|
||||||
|
listRooms: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -416,6 +433,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
const l = await ownLink(me, params.imvu);
|
const l = await ownLink(me, params.imvu);
|
||||||
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
|
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
|
||||||
await age.forgetAccount(l.imvuId);
|
await age.forgetAccount(l.imvuId);
|
||||||
|
await rooms.forgetOwner(l.imvuId);
|
||||||
realtime.unlinked(me.id, l.imvuId);
|
realtime.unlinked(me.id, l.imvuId);
|
||||||
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
||||||
set.status = 204;
|
set.status = 204;
|
||||||
@@ -450,6 +468,46 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
|
||||||
|
.get("/rooms", async ({ person: me }) => {
|
||||||
|
const viewerAdult =
|
||||||
|
me !== undefined &&
|
||||||
|
(
|
||||||
|
await age.adultAccounts(
|
||||||
|
me.id,
|
||||||
|
(await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id),
|
||||||
|
)
|
||||||
|
).size > 0;
|
||||||
|
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
|
||||||
|
const here = new Map<string, number[]>();
|
||||||
|
for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]);
|
||||||
|
const ids = [...new Set([...here.values()].flat())];
|
||||||
|
const shown = ids.length
|
||||||
|
? await db
|
||||||
|
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username, badge: imvuLink.showBadge })
|
||||||
|
.from(imvuLink)
|
||||||
|
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showPresence, true)))
|
||||||
|
: [];
|
||||||
|
const out = listed.map(({ room: r, owner }) => {
|
||||||
|
const t5de = shown.filter((u) => here.get(r.instanceId)?.includes(u.imvuId));
|
||||||
|
return {
|
||||||
|
instanceId: r.instanceId,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
imageURL: r.imageURL,
|
||||||
|
owner: { imvuId: r.ownerImvuId, username: owner },
|
||||||
|
adult: isAdultRoom(r),
|
||||||
|
occupancy: r.occupancy,
|
||||||
|
capacity: r.capacity,
|
||||||
|
url: `https://go.imvu.com/chat/room-${r.instanceId}`,
|
||||||
|
t5de,
|
||||||
|
seenAt: r.seenAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
out.sort((a, b) => b.t5de.length - a.t5de.length);
|
||||||
|
return { rooms: out };
|
||||||
|
})
|
||||||
|
|
||||||
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
|
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
|
||||||
.get(
|
.get(
|
||||||
"/age",
|
"/age",
|
||||||
@@ -645,7 +703,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
},
|
},
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const { person } = ws.data as unknown as { person: Person };
|
const { person } = ws.data as unknown as { person: Person };
|
||||||
realtime.open(ws.id, person.id, () => ws.close(4001, "signed out"));
|
realtime.open(
|
||||||
|
ws.id,
|
||||||
|
person.id,
|
||||||
|
() => ws.close(4001, "signed out"),
|
||||||
|
(m) => ws.send(JSON.stringify(m)),
|
||||||
|
);
|
||||||
ws.send(JSON.stringify({ type: "welcome" }));
|
ws.send(JSON.stringify({ type: "welcome" }));
|
||||||
},
|
},
|
||||||
async message(ws, message) {
|
async message(ws, message) {
|
||||||
@@ -663,7 +726,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
|||||||
.get("/health", () => ({ ok: true }))
|
.get("/health", () => ({ ok: true }))
|
||||||
.use(api);
|
.use(api);
|
||||||
|
|
||||||
return { app, realtime, age };
|
return { app, realtime, age, rooms };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type App = ReturnType<typeof createApp>["app"];
|
export type App = ReturnType<typeof createApp>["app"];
|
||||||
+59
-6
@@ -1,5 +1,6 @@
|
|||||||
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
|
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
|
||||||
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
|
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends
|
||||||
|
// on it for nothing else.
|
||||||
|
|
||||||
export type ImvuUser = {
|
export type ImvuUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,6 +10,21 @@ export type ImvuUser = {
|
|||||||
accessPass: boolean;
|
accessPass: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImvuRoom = {
|
||||||
|
instanceId: string; // "<owner>-<n>"
|
||||||
|
ownerId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
imageURL: string;
|
||||||
|
privacy: string; // "public", ...
|
||||||
|
type: string; // "listed", "unlisted", ...
|
||||||
|
contentRating: string; // "GA", "AP"
|
||||||
|
accessPass: boolean;
|
||||||
|
occupancy: number;
|
||||||
|
capacity: number;
|
||||||
|
expired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class NoSuchImvuUser extends Error {
|
export class NoSuchImvuUser extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("no such IMVU account");
|
super("no such IMVU account");
|
||||||
@@ -31,11 +47,22 @@ export class ImvuClient {
|
|||||||
byUsername(username: string): Promise<ImvuUser> {
|
byUsername(username: string): Promise<ImvuUser> {
|
||||||
const name = username.trim();
|
const name = username.trim();
|
||||||
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
|
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
|
||||||
return this.get(`/user?username=${encodeURIComponent(name)}`);
|
return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
byId(id: number): Promise<ImvuUser> {
|
byId(id: number): Promise<ImvuUser> {
|
||||||
return this.get(`/user/user-${id}`);
|
return this.get(`/user/user-${id}`).then(parse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room by its instance ID ("<owner>-<n>"); undefined if there's no such room. */
|
||||||
|
async room(instanceId: string): Promise<ImvuRoom | undefined> {
|
||||||
|
if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined;
|
||||||
|
try {
|
||||||
|
return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof NoSuchImvuUser) return undefined;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async wait() {
|
private async wait() {
|
||||||
@@ -45,7 +72,7 @@ export class ImvuClient {
|
|||||||
if (at > now) await Bun.sleep(at - now);
|
if (at > now) await Bun.sleep(at - now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async get(path: string): Promise<ImvuUser> {
|
private async get(path: string): Promise<unknown> {
|
||||||
await this.wait();
|
await this.wait();
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -58,10 +85,36 @@ export class ImvuClient {
|
|||||||
}
|
}
|
||||||
if (response.status === 404) throw new NoSuchImvuUser();
|
if (response.status === 404) throw new NoSuchImvuUser();
|
||||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||||
return parse(await response.json());
|
return response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined {
|
||||||
|
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||||
|
const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`));
|
||||||
|
const d = entry?.[1].data;
|
||||||
|
if (!d) return undefined;
|
||||||
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||||||
|
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||||
|
let image = str(d.image_url);
|
||||||
|
if (image.startsWith("//")) image = "https:" + image;
|
||||||
|
if (!image.startsWith("https://")) image = "";
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]),
|
||||||
|
name: str(d.name).slice(0, 200),
|
||||||
|
description: str(d.description).slice(0, 2000),
|
||||||
|
imageURL: image,
|
||||||
|
privacy: str(d.privacy),
|
||||||
|
type: str(d.type),
|
||||||
|
contentRating: str(d.content_rating),
|
||||||
|
accessPass: d.is_ap === true,
|
||||||
|
occupancy: num(d.occupancy),
|
||||||
|
capacity: num(d.capacity),
|
||||||
|
expired: d.is_expired === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||||
export function parse(answer: unknown): ImvuUser {
|
export function parse(answer: unknown): ImvuUser {
|
||||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
|||||||
|
|
||||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||||
const { app, age } = createApp({ db, auth, imvu: new ImvuClient() });
|
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||||
|
|
||||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||||
|
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||||
}, 10 * 60 * 1000);
|
}, 10 * 60 * 1000);
|
||||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||||
+127
-24
@@ -1,34 +1,69 @@
|
|||||||
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
|
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
|
||||||
// community products it wears. Later, room channels.
|
// 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).
|
||||||
//
|
//
|
||||||
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
||||||
// a bearer token):
|
// a bearer token):
|
||||||
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
||||||
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
|
// proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now
|
||||||
|
// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the
|
||||||
|
// IMVU room instance, for a room's chat)
|
||||||
|
// proxy -> hub {"type": "leave", "chat": "1379947638"}
|
||||||
|
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
|
||||||
|
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
|
||||||
|
// products only go to clients whose IMVU account counts as adult.
|
||||||
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
||||||
|
|
||||||
export type Item = { id: string; name: string };
|
export type Item = { id: string; name: string; adult: boolean };
|
||||||
|
|
||||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
|
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
||||||
|
|
||||||
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
|
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
|
||||||
type Session = { userId: string; imvuId: number; requested: number; since: Date; outfit: Item[]; close: () => void };
|
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
|
||||||
|
type Session = {
|
||||||
|
userId: string;
|
||||||
|
imvuId: number;
|
||||||
|
requested: number;
|
||||||
|
adult: boolean;
|
||||||
|
since: Date;
|
||||||
|
outfit: Item[];
|
||||||
|
chats: Map<string, string>;
|
||||||
|
lastSent: Map<string, string>;
|
||||||
|
send: (message: object) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type Owners = (imvuId: number) => Promise<string | undefined>;
|
export type RealtimeDeps = {
|
||||||
|
/** The person an IMVU account is linked to. */
|
||||||
|
owner: (imvuId: number) => Promise<string | undefined>;
|
||||||
|
/** Whether a person's IMVU account counts as adult (the age rule). */
|
||||||
|
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
||||||
|
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
||||||
|
room?: (instanceId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxChats = 16;
|
||||||
|
const chatPattern = /^[0-9]{1,20}$/;
|
||||||
|
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
||||||
|
|
||||||
export class Realtime {
|
export class Realtime {
|
||||||
private sessions = new Map<string, Session>();
|
private sessions = new Map<string, Session>();
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
private readonly deps: Required<RealtimeDeps>;
|
||||||
|
|
||||||
constructor(private readonly owner: Owners) {}
|
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
||||||
|
const d = typeof deps === "function" ? { owner: deps } : deps;
|
||||||
|
this.deps = { adult: async () => false, room: () => {}, ...d };
|
||||||
|
}
|
||||||
|
|
||||||
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
|
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||||
subscribe(fn: () => void): () => void {
|
subscribe(fn: () => void): () => void {
|
||||||
this.listeners.add(fn);
|
this.listeners.add(fn);
|
||||||
return () => this.listeners.delete(fn);
|
return () => this.listeners.delete(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private changed() {
|
private changed(chats: Iterable<string> = []) {
|
||||||
|
for (const chat of new Set(chats)) this.sync(chat);
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +74,8 @@ export class Realtime {
|
|||||||
if (!s.imvuId) continue;
|
if (!s.imvuId) continue;
|
||||||
const prev = byId.get(s.imvuId);
|
const prev = byId.get(s.imvuId);
|
||||||
if (prev && prev.since > s.since) continue;
|
if (prev && prev.since > s.since) continue;
|
||||||
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
|
const rooms = [...s.chats.values()].filter(Boolean);
|
||||||
|
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
|
||||||
}
|
}
|
||||||
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
|
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
|
||||||
}
|
}
|
||||||
@@ -48,40 +84,57 @@ export class Realtime {
|
|||||||
return this.sessions.size;
|
return this.sessions.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
open(id: string, userId: string, close: () => void) {
|
open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
|
||||||
this.sessions.set(id, { userId, imvuId: 0, requested: 0, since: new Date(), outfit: [], close });
|
this.sessions.set(id, {
|
||||||
|
userId,
|
||||||
|
imvuId: 0,
|
||||||
|
requested: 0,
|
||||||
|
adult: false,
|
||||||
|
since: new Date(),
|
||||||
|
outfit: [],
|
||||||
|
chats: new Map(),
|
||||||
|
lastSent: new Map(),
|
||||||
|
send,
|
||||||
|
close,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
close(id: string) {
|
close(id: string) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
if (s?.imvuId) this.changed();
|
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A person just linked an IMVU account: their clients already using it count now. */
|
/** A person just linked an IMVU account: their clients already using it count now. */
|
||||||
linked(userId: string, imvuId: number) {
|
async linked(userId: string, imvuId: number) {
|
||||||
|
const chats: string[] = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const s of this.sessions.values()) {
|
for (const s of this.sessions.values()) {
|
||||||
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
|
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
|
||||||
s.imvuId = imvuId;
|
s.imvuId = imvuId;
|
||||||
s.since = new Date();
|
s.since = new Date();
|
||||||
|
s.adult = await this.deps.adult(userId, imvuId);
|
||||||
|
chats.push(...s.chats.keys());
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) this.changed();
|
if (changed) this.changed(chats);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
|
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
|
||||||
unlinked(userId: string, imvuId: number) {
|
unlinked(userId: string, imvuId: number) {
|
||||||
|
const chats: string[] = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const s of this.sessions.values()) {
|
for (const s of this.sessions.values()) {
|
||||||
if (s.userId === userId && s.imvuId === imvuId) {
|
if (s.userId === userId && s.imvuId === imvuId) {
|
||||||
s.imvuId = 0;
|
s.imvuId = 0;
|
||||||
|
s.adult = false;
|
||||||
s.outfit = [];
|
s.outfit = [];
|
||||||
|
chats.push(...s.chats.keys());
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) this.changed();
|
if (changed) this.changed(chats);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Closes a person's connections (banned, or signed out of their computers). */
|
/** Closes a person's connections (banned, or signed out of their computers). */
|
||||||
@@ -89,27 +142,59 @@ export class Realtime {
|
|||||||
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */
|
||||||
|
private sync(chat: string) {
|
||||||
|
const members = [...this.sessions.values()].filter((s) => s.chats.has(chat));
|
||||||
|
for (const viewer of members) {
|
||||||
|
const avatars = new Map<number, Item[]>();
|
||||||
|
for (const other of members) {
|
||||||
|
if (!other.imvuId || other.imvuId === viewer.imvuId) continue;
|
||||||
|
avatars.set(
|
||||||
|
other.imvuId,
|
||||||
|
other.outfit.filter((it) => !it.adult || viewer.adult),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = {
|
||||||
|
type: "chat",
|
||||||
|
chat,
|
||||||
|
avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })),
|
||||||
|
};
|
||||||
|
const text = JSON.stringify(message);
|
||||||
|
if (viewer.lastSent.get(chat) === text) continue;
|
||||||
|
viewer.lastSent.set(chat, text);
|
||||||
|
viewer.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles a message from a proxy; returns the reply to send, if any. */
|
/** Handles a message from a proxy; returns the reply to send, if any. */
|
||||||
async message(id: string, raw: unknown): Promise<object | undefined> {
|
async message(id: string, raw: unknown): Promise<object | undefined> {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
|
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as {
|
||||||
|
type?: unknown;
|
||||||
|
imvu_id?: unknown;
|
||||||
|
items?: unknown;
|
||||||
|
chat?: unknown;
|
||||||
|
room?: unknown;
|
||||||
|
};
|
||||||
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
||||||
if (m.type === "session") {
|
if (m.type === "session") {
|
||||||
const imvuId = Number(m.imvu_id);
|
const imvuId = Number(m.imvu_id);
|
||||||
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||||
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
|
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
||||||
if (owner !== s.userId) {
|
if (owner !== s.userId) {
|
||||||
// Not this person's account: no presence, no outfit for it.
|
// Not this person's account: no presence, no outfit for it.
|
||||||
s.imvuId = 0;
|
s.imvuId = 0;
|
||||||
|
s.adult = false;
|
||||||
s.outfit = [];
|
s.outfit = [];
|
||||||
this.changed();
|
this.changed(s.chats.keys());
|
||||||
return { type: "error", message: "not_linked" };
|
return { type: "error", message: "not_linked" };
|
||||||
}
|
}
|
||||||
s.imvuId = imvuId;
|
s.imvuId = imvuId;
|
||||||
s.since = new Date();
|
s.since = new Date();
|
||||||
s.outfit = [];
|
s.outfit = [];
|
||||||
this.changed();
|
s.adult = await this.deps.adult(s.userId, imvuId);
|
||||||
|
this.changed(s.chats.keys());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m.type === "outfit") {
|
if (m.type === "outfit") {
|
||||||
@@ -119,8 +204,26 @@ export class Realtime {
|
|||||||
s.outfit = items
|
s.outfit = items
|
||||||
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
||||||
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
||||||
.map((it) => ({ id: it.id, name: it.name }));
|
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
||||||
this.changed();
|
this.changed(s.chats.keys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type === "join" || m.type === "leave") {
|
||||||
|
const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? "");
|
||||||
|
if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" };
|
||||||
|
if (m.type === "leave") {
|
||||||
|
if (!s.chats.delete(chat)) return;
|
||||||
|
s.lastSent.delete(chat);
|
||||||
|
this.changed([chat]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : "";
|
||||||
|
if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" };
|
||||||
|
s.chats.set(chat, room);
|
||||||
|
s.lastSent.delete(chat); // tell it again, even if nothing changed
|
||||||
|
if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId);
|
||||||
|
if (room) this.deps.room(room);
|
||||||
|
this.changed([chat]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return { type: "error", message: "unknown message type" };
|
return { type: "error", message: "unknown message type" };
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// The rooms list: IMVU rooms owned by linked accounts that chose to list them. The hub learns of a room when a T5DE
|
||||||
|
// user enters it, and asks IMVU about it (owner, public and listed or not); it's checked again while it's in use,
|
||||||
|
// so a room made unlisted or closed drops off the list.
|
||||||
|
import { and, desc, eq, gt, lt, schema, type Database } from "@t5de/db";
|
||||||
|
import type { ImvuClient } from "./imvu";
|
||||||
|
|
||||||
|
const { room, imvuLink } = schema;
|
||||||
|
|
||||||
|
export type Room = typeof room.$inferSelect;
|
||||||
|
|
||||||
|
const recheckAfter = 60 * 60 * 1000;
|
||||||
|
// Rooms nobody entered for this long aren't checked again or listed.
|
||||||
|
const forgetAfter = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Whether IMVU says anyone may find and enter the room. */
|
||||||
|
export const isListable = (r: Pick<Room, "privacy" | "type" | "expired">) =>
|
||||||
|
r.privacy === "public" && r.type === "listed" && !r.expired;
|
||||||
|
|
||||||
|
/** Whether the room is for adults (Access Pass rooms). */
|
||||||
|
export const isAdultRoom = (r: Pick<Room, "contentRating" | "accessPass">) => r.accessPass || r.contentRating === "AP";
|
||||||
|
|
||||||
|
export class Rooms {
|
||||||
|
private checking = new Set<string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly imvu: ImvuClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** A T5DE user entered a room: remember it if its owner is linked, asking IMVU about it if it's due. */
|
||||||
|
async seen(instanceId: string) {
|
||||||
|
const ownerId = Number(instanceId.split("-")[0]);
|
||||||
|
const [owner] = await this.db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.imvuId, ownerId));
|
||||||
|
if (!owner) return;
|
||||||
|
const [known] = await this.db.select().from(room).where(eq(room.instanceId, instanceId));
|
||||||
|
if (known && Date.now() - known.checkedAt.getTime() < recheckAfter) {
|
||||||
|
await this.db.update(room).set({ seenAt: new Date() }).where(eq(room.instanceId, instanceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.check(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks IMVU about a room again, and records what it says (or forgets the room if it's gone). */
|
||||||
|
async check(instanceId: string) {
|
||||||
|
if (this.checking.has(instanceId)) return;
|
||||||
|
this.checking.add(instanceId);
|
||||||
|
try {
|
||||||
|
const r = await this.imvu.room(instanceId);
|
||||||
|
if (!r || String(r.ownerId) !== instanceId.split("-")[0]) {
|
||||||
|
await this.db.delete(room).where(eq(room.instanceId, instanceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = {
|
||||||
|
ownerImvuId: r.ownerId,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
imageURL: r.imageURL,
|
||||||
|
privacy: r.privacy,
|
||||||
|
type: r.type,
|
||||||
|
contentRating: r.contentRating,
|
||||||
|
accessPass: r.accessPass,
|
||||||
|
occupancy: r.occupancy,
|
||||||
|
capacity: r.capacity,
|
||||||
|
expired: r.expired,
|
||||||
|
checkedAt: new Date(),
|
||||||
|
seenAt: new Date(),
|
||||||
|
};
|
||||||
|
await this.db
|
||||||
|
.insert(room)
|
||||||
|
.values({ instanceId, ...values })
|
||||||
|
.onConflictDoUpdate({ target: room.instanceId, set: values });
|
||||||
|
} finally {
|
||||||
|
this.checking.delete(instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks again rooms in use whose answer is old (at most limit). */
|
||||||
|
async recheckDue(limit = 20): Promise<number> {
|
||||||
|
const due = await this.db
|
||||||
|
.select({ instanceId: room.instanceId })
|
||||||
|
.from(room)
|
||||||
|
.where(and(lt(room.checkedAt, new Date(Date.now() - recheckAfter)), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
|
||||||
|
.limit(limit);
|
||||||
|
let n = 0;
|
||||||
|
for (const r of due) {
|
||||||
|
try {
|
||||||
|
await this.check(r.instanceId);
|
||||||
|
n++;
|
||||||
|
} catch {
|
||||||
|
// IMVU unavailable: try again next time.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rooms to list: listable, owner chose to list, entered lately; with the owner's IMVU name. */
|
||||||
|
async listed() {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ room, owner: imvuLink.username })
|
||||||
|
.from(room)
|
||||||
|
.innerJoin(imvuLink, eq(imvuLink.imvuId, room.ownerImvuId))
|
||||||
|
.where(and(eq(imvuLink.listRooms, true), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
|
||||||
|
.orderBy(desc(room.seenAt));
|
||||||
|
return rows.filter((r) => isListable(r.room));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets an IMVU account's rooms (it was unlinked). */
|
||||||
|
async forgetOwner(imvuId: number) {
|
||||||
|
await this.db.delete(room).where(eq(room.ownerImvuId, imvuId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ describe("T5DE's proxy", () => {
|
|||||||
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
||||||
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
||||||
expect(online).toEqual([
|
expect(online).toEqual([
|
||||||
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] },
|
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat", adult: false }] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// A ban closes the connection and ends the proxy's session.
|
// A ban closes the connection and ends the proxy's session.
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// The room channel (T5DE users in the same IMVU chat see each other's community products) and the rooms list.
|
||||||
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||||
|
import { openDatabase, schema, 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<number, ImvuUser>();
|
||||||
|
const rooms = new Map<string, Record<string, unknown>>();
|
||||||
|
|
||||||
|
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const room = url.pathname.match(/^\/room\/room-(\d+-\d+)$/);
|
||||||
|
if (room) {
|
||||||
|
const r = rooms.get(room[1]!);
|
||||||
|
return Response.json({
|
||||||
|
denormalized: r ? { [`https://api.imvu.com/room/room-${room[1]}`]: { data: r } } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
const imvuRoom = (owner: number, n: number, over: Record<string, unknown> = {}) =>
|
||||||
|
rooms.set(`${owner}-${n}`, {
|
||||||
|
name: `Room ${n}`,
|
||||||
|
description: "<b>hi</b>",
|
||||||
|
type: "listed",
|
||||||
|
privacy: "public",
|
||||||
|
image_url: "//webasset-akm.imvu.com/room.gif",
|
||||||
|
occupancy: 2,
|
||||||
|
capacity: 10,
|
||||||
|
content_rating: "GA",
|
||||||
|
is_ap: false,
|
||||||
|
customers_id: owner,
|
||||||
|
is_expired: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
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, res };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs a person up with one linked IMVU account; token is a session token for their proxy. */
|
||||||
|
async function person(name: string, imvu: { id: number; ageVerified?: boolean }): Promise<Person> {
|
||||||
|
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("");
|
||||||
|
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false });
|
||||||
|
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
|
||||||
|
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
|
||||||
|
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A proxy's live connection: everything the hub sent it, and the latest "chat" message per chat. */
|
||||||
|
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));
|
||||||
|
send({ type: "session", imvu_id: imvuId });
|
||||||
|
return {
|
||||||
|
ws,
|
||||||
|
got,
|
||||||
|
send,
|
||||||
|
chat: (chat: string) => got.filter((m) => m.type === "chat" && m.chat === chat).at(-1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(ok: () => boolean) {
|
||||||
|
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
||||||
|
expect(ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("T5DE users in the same chat see each other's community products; adult ones only if adult", async () => {
|
||||||
|
const fiona = await person("fiona", { id: 101, ageVerified: true });
|
||||||
|
const gus = await person("gus", { id: 102 });
|
||||||
|
const f = await proxy(fiona, 101);
|
||||||
|
const g = await proxy(gus, 102);
|
||||||
|
f.send({ type: "outfit", items: [{ id: "coat", name: "Coat" }, { id: "lace", name: "Lace", adult: true }] });
|
||||||
|
g.send({ type: "outfit", items: [{ id: "hat", name: "Hat" }] });
|
||||||
|
|
||||||
|
f.send({ type: "join", chat: "555", room: "101-1" });
|
||||||
|
g.send({ type: "join", chat: "555", room: "101-1" });
|
||||||
|
await waitFor(() => g.chat("555")?.avatars.length === 1 && f.chat("555")?.avatars.length === 1);
|
||||||
|
expect(g.chat("555").avatars).toEqual([{ imvu_id: 101, items: [{ id: "coat", name: "Coat", adult: false }] }]);
|
||||||
|
expect(f.chat("555").avatars).toEqual([{ imvu_id: 102, items: [{ id: "hat", name: "Hat", adult: false }] }]);
|
||||||
|
|
||||||
|
// Outfit changes reach the others in the chat; a chat they're not in hears nothing.
|
||||||
|
g.send({ type: "join", chat: "777" });
|
||||||
|
g.send({ type: "outfit", items: [] });
|
||||||
|
await waitFor(() => f.chat("555")?.avatars[0]?.items.length === 0);
|
||||||
|
expect(f.chat("777")).toBeUndefined();
|
||||||
|
|
||||||
|
// Leaving (or disconnecting) takes them off the others' list.
|
||||||
|
g.send({ type: "leave", chat: "555" });
|
||||||
|
await waitFor(() => f.chat("555")?.avatars.length === 0);
|
||||||
|
g.send({ type: "join", chat: "555" });
|
||||||
|
await waitFor(() => f.chat("555")?.avatars.length === 1);
|
||||||
|
g.ws.close();
|
||||||
|
await waitFor(() => f.chat("555")?.avatars.length === 0);
|
||||||
|
|
||||||
|
// Nonsense is refused.
|
||||||
|
f.send({ type: "join", chat: "../x" });
|
||||||
|
await waitFor(() => f.got.some((m) => m.message === "bad chat"));
|
||||||
|
f.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rooms are listed when public and listed, and their owner chose to list them", async () => {
|
||||||
|
const hana = await person("hana", { id: 201, ageVerified: true });
|
||||||
|
const ivan = await person("ivan", { id: 202 });
|
||||||
|
imvuRoom(201, 1);
|
||||||
|
imvuRoom(201, 2, { type: "unlisted" });
|
||||||
|
imvuRoom(201, 3, { content_rating: "AP", is_ap: true });
|
||||||
|
imvuRoom(201, 4, { privacy: "closed" });
|
||||||
|
const h = await proxy(hana, 201);
|
||||||
|
for (const [chat, room] of [["1", "201-1"], ["2", "201-2"], ["3", "201-3"], ["4", "201-4"]]) h.send({ type: "join", chat, room });
|
||||||
|
// A room whose owner isn't on T5DE is never stored.
|
||||||
|
imvuRoom(999, 1);
|
||||||
|
h.send({ type: "join", chat: "9", room: "999-1" });
|
||||||
|
|
||||||
|
const names = async (p?: Person) => ((await call(p, "GET", "/api/v1/rooms")).json.rooms as any[]).map((r) => r.name);
|
||||||
|
// The hub asks IMVU about each (spaced out); until Hana chooses to list them, none are listed.
|
||||||
|
for (let i = 0; i < 300 && (await db.select().from(schema.room)).length < 4; i++) await Bun.sleep(10);
|
||||||
|
expect((await db.select().from(schema.room)).map((r) => r.instanceId).sort()).toEqual(["201-1", "201-2", "201-3", "201-4"]);
|
||||||
|
expect(await names()).toEqual([]);
|
||||||
|
await call(hana, "PATCH", "/api/v1/account/links/201", { listRooms: true });
|
||||||
|
expect(await names()).toEqual(["Room 1"]);
|
||||||
|
expect(await names(ivan)).toEqual(["Room 1"]); // Ivan isn't adult
|
||||||
|
expect((await names(hana)).sort()).toEqual(["Room 1", "Room 3"]);
|
||||||
|
|
||||||
|
const [listed] = (await call(undefined, "GET", "/api/v1/rooms")).json.rooms;
|
||||||
|
expect(listed).toMatchObject({
|
||||||
|
instanceId: "201-1",
|
||||||
|
description: "<b>hi</b>", // the website shows it as text
|
||||||
|
imageURL: "https://webasset-akm.imvu.com/room.gif",
|
||||||
|
owner: { imvuId: 201, username: "hanaAv" },
|
||||||
|
url: "https://go.imvu.com/chat/room-201-1",
|
||||||
|
t5de: [], // Hana is there, but doesn't show she's online
|
||||||
|
});
|
||||||
|
await call(hana, "PATCH", "/api/v1/account/links/201", { showPresence: true });
|
||||||
|
expect((await call(undefined, "GET", "/api/v1/rooms")).json.rooms[0].t5de).toEqual([{ imvuId: 201, username: "hanaAv", badge: false }]);
|
||||||
|
|
||||||
|
// Unlinking forgets the account's rooms.
|
||||||
|
await call(hana, "DELETE", "/api/v1/account/links/201");
|
||||||
|
expect(await names(hana)).toEqual([]);
|
||||||
|
h.ws.close();
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ export async function linkAction(_: ActionState, form: FormData): Promise<Action
|
|||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
imvu: number,
|
imvu: number,
|
||||||
settings: { showBadge?: boolean; showPresence?: boolean },
|
settings: { showBadge?: boolean; showPresence?: boolean; listRooms?: boolean },
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
return attempt(async () => {
|
return attempt(async () => {
|
||||||
unwrap(await api.account.links({ imvu }).patch(settings));
|
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export function LinkCard({ link }: { link: Link }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Outfit items={link.outfit} />
|
<Outfit items={link.outfit} />
|
||||||
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
|
<LinkSettings
|
||||||
|
imvuId={link.imvuId}
|
||||||
|
showBadge={link.showBadge}
|
||||||
|
showPresence={link.showPresence}
|
||||||
|
listRooms={link.listRooms}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||||
<div className="flex gap-2 sm:justify-end">
|
<div className="flex gap-2 sm:justify-end">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import type { ActionState } from "@/lib/actions";
|
import type { ActionState } from "@/lib/actions";
|
||||||
import { saveSettings } from "./actions";
|
import { saveSettings } from "./actions";
|
||||||
|
|
||||||
type Settings = { showBadge: boolean; showPresence: boolean };
|
type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean };
|
||||||
|
|
||||||
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
||||||
const [settings, setOptimistic] = useOptimistic(saved);
|
const [settings, setOptimistic] = useOptimistic(saved);
|
||||||
@@ -29,6 +29,10 @@ export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }
|
|||||||
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||||
Show when I'm online in T5DE
|
Show when I'm online in T5DE
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.listRooms} onCheckedChange={change("listRooms")} disabled={pending} />
|
||||||
|
List my public rooms
|
||||||
|
</label>
|
||||||
<FormMessage state={state} />
|
<FormMessage state={state} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Rooms" };
|
||||||
|
|
||||||
|
export default async function Rooms() {
|
||||||
|
const { rooms } = await api.rooms.get().then(unwrap);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
|
||||||
|
{rooms.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<li key={r.instanceId}>
|
||||||
|
<Card className="h-full overflow-hidden py-0">
|
||||||
|
{r.imageURL && (
|
||||||
|
// IMVU's own image of the room, from IMVU.
|
||||||
|
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||||
|
)}
|
||||||
|
<CardContent className="space-y-3 pb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
||||||
|
{r.name}
|
||||||
|
</a>
|
||||||
|
{r.adult && <Tag tone="adult">AP</Tag>}
|
||||||
|
{r.t5de.length > 0 && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
{r.t5de.length} on T5DE
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{r.owner.username} · {r.occupancy}/{r.capacity}
|
||||||
|
</p>
|
||||||
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||||
|
{r.t5de.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export async function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||||
<NavLink href="/online">Online</NavLink>
|
<NavLink href="/online">Online</NavLink>
|
||||||
|
<NavLink href="/rooms">Rooms</NavLink>
|
||||||
{me && <NavLink href="/account">Account</NavLink>}
|
{me && <NavLink href="/account">Account</NavLink>}
|
||||||
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+3
-2
@@ -29,7 +29,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
|||||||
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
|
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
|
||||||
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
|
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
|
||||||
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
|
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
|
||||||
| `GET /api/v1/realtime` | WebSocket, bearer token. Protocol in `apps/api/src/realtime.ts`. |
|
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, and the room channel. Protocol in `apps/api/src/realtime.ts`. |
|
||||||
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
||||||
|
|
||||||
## Website routes (signed in unless noted)
|
## Website routes (signed in unless noted)
|
||||||
@@ -39,11 +39,12 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
|||||||
| `GET /api/v1/home` | (anyone) `{onlineCount, links?}` |
|
| `GET /api/v1/home` | (anyone) `{onlineCount, links?}` |
|
||||||
| `GET /api/v1/online` | (anyone) `{online}`: accounts showing they're online (moderators see all, with `hidden`) |
|
| `GET /api/v1/online` | (anyone) `{online}`: accounts showing they're online (moderators see all, with `hidden`) |
|
||||||
| `GET /api/v1/online/events` | (anyone) server-sent events: `online` with `{online}` now and on each change |
|
| `GET /api/v1/online/events` | (anyone) server-sent events: `online` with `{online}` now and on each change |
|
||||||
|
| `GET /api/v1/rooms` | (anyone) `{rooms}`: listed IMVU rooms of T5DE users, with the T5DE users in each now (AP rooms for adults only) |
|
||||||
| `GET /api/v1/account` | `{person, links, challenges, computers, canLink}` |
|
| `GET /api/v1/account` | `{person, links, challenges, computers, canLink}` |
|
||||||
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
|
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
|
||||||
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
|
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
|
||||||
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
|
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
|
||||||
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?}` → `{link}` |
|
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` |
|
||||||
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
|
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
|
||||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
||||||
|
|||||||
+24
-3
@@ -243,6 +243,20 @@ number, `productdata/<id>/<revision>/`) but hardly shows them; T5DE shows them o
|
|||||||
3. Other T5DE users' proxies add the items to that user's incoming `*use`, filtered by their own age settings.
|
3. Other T5DE users' proxies add the items to that user's incoming `*use`, filtered by their own age settings.
|
||||||
Non-T5DE users see the default.
|
Non-T5DE users see the default.
|
||||||
|
|
||||||
|
How it's built (2026-10-29):
|
||||||
|
- The proxy sees the client's IMQ subscriptions to chat queues (`/chat/<id>`) and tells the hub which chats it's
|
||||||
|
in; for a room's chat, also the room (from `chat.getOrMakeChat`, `activity: publicroom-<owner>-<n>`).
|
||||||
|
- The hub tells each proxy in a chat about the other T5DE users there and what they wear (adult products only to
|
||||||
|
clients whose account counts as adult), on joining and on every change.
|
||||||
|
- The proxy delivers `*use` / `*remove` lines from those users to the client (IMQ messages of its own, without a
|
||||||
|
sequence number, which the client doesn't track), and adds their community products to the whole outfits they
|
||||||
|
send (`*use` with the body, re-sent on joins and every 30 s or so), which would otherwise put IMVU's back.
|
||||||
|
- Products the viewer has no pack for are skipped (logged), until catalogs serve them.
|
||||||
|
- The client also posts its outfit to IMVU's chat participant endpoint (`legacy_outfit_message`); the proxy takes
|
||||||
|
community products out of it and re-signs it.
|
||||||
|
- Anyone signed in can say they're in a chat; what they learn is what T5DE users in it wear, which anyone in the
|
||||||
|
chat sees anyway.
|
||||||
|
|
||||||
Saved outfits are per IMVU account (avatars differ). The library (products added, favorites, followed creators)
|
Saved outfits are per IMVU account (avatars differ). The library (products added, favorites, followed creators)
|
||||||
is per person, shared across their accounts.
|
is per person, shared across their accounts.
|
||||||
|
|
||||||
@@ -257,6 +271,12 @@ is per person, shared across their accounts.
|
|||||||
- **Chat retention** (**Decided**): not stored by default. When someone reports a conversation, the recent messages
|
- **Chat retention** (**Decided**): not stored by default. When someone reports a conversation, the recent messages
|
||||||
around the report are kept as evidence for 30 days, then deleted.
|
around the report are kept as evidence for 30 days, then deleted.
|
||||||
- Room shells and furniture are ordinary products.
|
- Room shells and furniture are ordinary products.
|
||||||
|
- **Rooms list** (2026-10-29, the user's request): the hub's Rooms page lists IMVU rooms of T5DE users that are
|
||||||
|
public, listed and not expired on IMVU (`privacy: public`, `type: listed`), and whose owner turned on "List my
|
||||||
|
public rooms" for that IMVU account (off by default, like the badge). IMVU's public API can't list a user's rooms,
|
||||||
|
so the hub learns of a room when a T5DE user enters it, asks IMVU about it, and asks again hourly while it's
|
||||||
|
used; rooms nobody entered for 30 days drop off. AP rooms are shown only to people with an adult account. Each
|
||||||
|
shows the T5DE users in it now who show they're online.
|
||||||
|
|
||||||
## Presence and badge (**Decided**: both opt-in, per IMVU account)
|
## Presence and badge (**Decided**: both opt-in, per IMVU account)
|
||||||
|
|
||||||
@@ -363,9 +383,10 @@ The proxy streams and adds no waiting.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
|
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
|
||||||
- Phase 2 (hub identity) under way: accounts, tagline linking, age, link caps, device sign-in, presence and badge,
|
- Phase 2 (hub identity) under way: accounts, tagline linking, age (vendor-neutral, IMVU's re-checked daily), link
|
||||||
console. Not yet: periodic age re-checks (on demand only), a sign-in button in the client (the proxy's `signin`
|
caps, device sign-in, presence and badge, console, the room channel and the rooms list. Not yet: a sign-in button
|
||||||
command for now), deployment.
|
in the client (the proxy's `signin` command for now), deployment, hosted rooms. The room channel is tested with
|
||||||
|
fake proxies and one live client; two live clients need a second throwaway account.
|
||||||
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
|
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
|
||||||
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
|
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
|
||||||
document says Go or templ for the hub, read that; catalogs are still to be decided (a Bun program compiled to one
|
document says Go or templ for the hub, read that; catalogs are still to be decided (a Bun program compiled to one
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "room" (
|
||||||
|
"instance_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"owner_imvu_id" bigint NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"image_url" text DEFAULT '' NOT NULL,
|
||||||
|
"privacy" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"content_rating" text DEFAULT '' NOT NULL,
|
||||||
|
"access_pass" boolean DEFAULT false NOT NULL,
|
||||||
|
"occupancy" integer DEFAULT 0 NOT NULL,
|
||||||
|
"capacity" integer DEFAULT 0 NOT NULL,
|
||||||
|
"expired" boolean DEFAULT false NOT NULL,
|
||||||
|
"checked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"seen_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "imvu_link" ADD COLUMN "list_rooms" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "room_owner" ON "room" USING btree ("owner_imvu_id");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1793277687785,
|
"when": 1793277687785,
|
||||||
"tag": "0001_age_checks",
|
"tag": "0001_age_checks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793321456968,
|
||||||
|
"tag": "0002_rooms",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -139,10 +139,34 @@ export const imvuLink = pgTable(
|
|||||||
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
|
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
showBadge: boolean("show_badge").notNull().default(false),
|
showBadge: boolean("show_badge").notNull().default(false),
|
||||||
showPresence: boolean("show_presence").notNull().default(false),
|
showPresence: boolean("show_presence").notNull().default(false),
|
||||||
|
listRooms: boolean("list_rooms").notNull().default(false),
|
||||||
},
|
},
|
||||||
(t) => [index("imvu_link_user").on(t.userId)],
|
(t) => [index("imvu_link_user").on(t.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// An IMVU room a T5DE user entered whose owner is a linked account, as IMVU last described it. Listed on the hub
|
||||||
|
// only if its owner chose to list their rooms and IMVU says it's public and listed.
|
||||||
|
export const room = pgTable(
|
||||||
|
"room",
|
||||||
|
{
|
||||||
|
instanceId: text("instance_id").primaryKey(), // "<owner>-<n>"
|
||||||
|
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description").notNull().default(""),
|
||||||
|
imageURL: text("image_url").notNull().default(""),
|
||||||
|
privacy: text("privacy").notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
contentRating: text("content_rating").notNull().default(""),
|
||||||
|
accessPass: boolean("access_pass").notNull().default(false),
|
||||||
|
occupancy: integer("occupancy").notNull().default(0),
|
||||||
|
capacity: integer("capacity").notNull().default(0),
|
||||||
|
expired: boolean("expired").notNull().default(false),
|
||||||
|
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
seenAt: timestamp("seen_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("room_owner").on(t.ownerImvuId)],
|
||||||
|
);
|
||||||
|
|
||||||
// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a
|
// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a
|
||||||
// birth date, document, photo or name (providers that see those keep them).
|
// birth date, document, photo or name (providers that see those keep them).
|
||||||
export const ageCheck = pgTable(
|
export const ageCheck = pgTable(
|
||||||
|
|||||||
Reference in new issue
Block a user