Compare commits

20 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 21e1bd8790 A slim Staging bar on staging
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
Shown above the header when the site is served from a staging. address, or with HUB_ENVIRONMENT=staging
(production hides it), read per request since staging and production run the same image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:09:22 -08:00
dhkatzandClaude Opus 5.5 975e11e077 CI builds and pushes the images; Komodo only deploys
CI / test (push) Successful in 54s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 7s
After the tests pass, main pushes :latest-main and :main-<commit>, and vX.Y.Z tags push :X.Y.Z, :X.Y and
:latest to git.t5de.dev (registry login: the t5de org secret REGISTRY_TOKEN). Komodo deploys them once the
ref's CI has passed; it no longer builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:53:10 +00:00
dhkatzandClaude Opus 5.5 7d20426d1b CI only tests again: Komodo builds and deploys
CI / test (push) Successful in 1m10s
Komodo now watches the t5de org (Gitea webhook → its t5de-push action, defined in t5de-infra): it builds and
deploys a commit to staging once this CI passes on main, and to prod for vX.Y.Z tags. So the deploy job and
.gitea/scripts/deploy.py go, and this repo no longer needs to know how it's deployed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:47:58 +00:00
dhkatzandClaude Opus 5.5 e3770ceb57 Deploy with Komodo: main to staging, vX.Y.Z to prod
CI / test (push) Successful in 1m6s
CI / deploy (push) Successful in 1m15s
CI now builds the API and website images after the tests, pushes them to git.t5de.dev, and deploys them through
Komodo (.gitea/scripts/deploy.py): pushes to main as :main to hub.staging.t5de.dev, vX.Y.Z tags as :X.Y.Z to
hub.t5de.dev, with the version pinned in t5de/t5de-infra.

The API image installs with --linker hoisted: Bun's isolated layout keeps the workspace links (@t5de/db) in each
package's own node_modules, which the image didn't copy, so the API couldn't start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:16:05 -07:00
dhkatzandClaude Opus 5.5 97ce2be569 Deleting products: by their creators, and removal by moderation
CI / test (push) Successful in 1m4s
A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it
keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay
available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it
leaves libraries and rooms. Both are on the product's page and in the audit log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:10:24 -07:00
dhkatzandClaude Opus 5.5 07bbc11565 Website header: the T5DE logo, four links, and a profile menu
CI / test (push) Successful in 1m11s
The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and
Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu
under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an
hour), or their initial. Creating a product moves to Products (New product, /products/new; /create
redirects there), as creating a room is on Rooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:18:26 -07:00
dhkatzandClaude Opus 5.5 76c8e0342e Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
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]>
2026-10-30 06:07:41 -07:00
dhkatzandClaude Opus 5.5 43183ec7ee Hosted rooms' instance IDs name their owner: <owner>-<2100000000+n>
CI / test (push) Successful in 58s
The client takes a room's owner from the instance ID's first part; with 2100000000 there, the owner
couldn't decorate their hosted room (putting furniture on went to their home room instead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:18:19 -07:00
dhkatzandClaude Opus 5.5 f65f36c1ea Decorations over IMVU rooms: the hub's side
CI / test (push) Successful in 1m13s
T5 products placed in ordinary IMVU rooms, kept per room instance
(room_decoration, migration 0006). GET /api/v1/decorations/:instance (and
?instances= for up to 20) gives what the viewer may see, with a hash;
PUT is for the room owner's T5DE person only, checks the products exist,
and tells every realtime session with a chat in that room ({"type":
"decor"}). Hosted rooms' IDs are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:58:42 -07:00
dhkatzandClaude Opus 5.5 2a1fc94df6 Hosted rooms: the hub's side
CI / test (push) Successful in 1m5s
Rooms made on the website (hosted_room, migration 0005), the REST routes in
docs/hosted-rooms.md, and the realtime room.* protocol: joining with the
capacity, adult and linked-account rules, seats, numbered messages with
forged senders dropped and a rate limit, closing. The Rooms page lists them,
with a form to make one and the owner's edit and close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:21 -07:00
dhkatzandClaude Opus 5.5 0c67232425 Badges: the proxy asks which IMVU accounts show the T5 badge
CI / test (push) Successful in 49s
POST /api/v1/badges {imvuIds} (at most 200) answers the linked accounts whose
badge is on, of people who aren't banned, in one query. It uses the existing
per-account showBadge (off by default, set from the account page).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:38:01 -07:00
dhkatzandClaude Opus 5.5 6668b55117 Products: carry the IMVU parent's image and categories
CI / test (push) Successful in 49s
Revisions with an IMVU parent store its public info (image, category path, gender)
from IMVU's product API, looked up after registering and in a periodic pass.
Product views gain parent, image and categories; GET /products takes category and
limit. The website shows the image on cards and product pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:03:02 -07:00
dhkatzandClaude Opus 5.5 d57674d90c Products: images need https addresses; lowering a rating waits for review; library shows creators
CI / test (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:14:12 -07:00
dhkatzandClaude Opus 5.5 51d5bb5ff3 Website: products, library, catalogs and their console page
/products (search), product pages with revisions, getting and staying on a
revision, retracting; /products/:id/edit; /create and new revisions upload
from the browser straight to the catalog with a token from the hub;
/library; /catalogs (directory, registering with a once-shown key);
/console/catalogs to approve or reject. Every page now sends a nonce-based
CSP (proxy.ts), so product descriptions can't run scripts.

buttonVariants now merges classes, so outline links get their border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:06:08 -07:00
dhkatzandClaude Opus 5.5 b4f6fce9df Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s
The hub registers catalogs (hidden until a moderator approves them), hands out upload tokens, and records the
revisions catalogs register after validating uploads: numbered, never changed, signed by the hub (its own
Ed25519 key, published at /.well-known/t5de-keys.json). Creators edit product info (descriptions cleaned by an
allowlist) and can retract revisions. People get products into their library, optionally staying on a
revision; the proxy asks which revision to load and where. Contract in docs/products.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:53:52 -07:00
dhkatzandClaude Opus 5.5 7fea63fc4f Design: everywhere the T5 badge appears
CI / test (push) Successful in 37s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:41:43 -07:00
dhkatzandClaude Opus 5.5 143aeb3d84 Design: T5 products in the client's shop and inventory
CI / test (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:38:23 -07:00
dhkatzandClaude Opus 5.5 6ba4d83317 Room channel and rooms list
CI / test (push) Successful in 6m38s
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]>
2026-10-29 18:14:43 -07:00
dhkatzandClaude Opus 5.5 7db9fc0370 Vendor-neutral age verification, with IMVU as the first provider
CI / test (push) Successful in 35s
Age checks are recorded in one table (age_check) by provider, method and result, never with documents or
birth dates; one rule decides whether an IMVU account counts as adult (its person's check, or its own).
Providers implement a small interface (start, webhook or browser completion, re-check) and are chosen by
configuration, so phone wallets and Didit can be added, or swapped, as adapters. IMVU's answer per account
is re-checked when it expires (about daily). Existing links' results move into the new table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:44:36 -07:00
dhkatzandClaude Opus 5.5 339ba8e2d6 Presence: count an account as soon as it's linked; accept an empty outfit
CI / test (push) Successful in 36s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:07:28 -07:00
87 changed files with 21384 additions and 157 deletions

No files matched your search

+37 -1
View File
@@ -1,9 +1,11 @@
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub. # Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub; then, for main and vX.Y.Z
# tags, builds its images and pushes them to git.t5de.dev. Komodo deploys them (dhkatz/infra); CI never deploys.
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -32,3 +34,37 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
# main → :latest-main and :main-<commit>; vX.Y.Z → :X.Y.Z, :X.Y and :latest. Built by the runner's Docker.
image:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { image: t5de-hub-api, dockerfile: apps/api/Dockerfile }
- { image: t5de-hub-web, dockerfile: apps/web/Dockerfile }
steps:
- uses: actions/checkout@v4
- name: Build and push
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
IMAGE: git.t5de.dev/t5de/${{ matrix.image }}
DOCKERFILE: ${{ matrix.dockerfile }}
run: |
case "$GITHUB_REF" in
refs/heads/main) version="main-${GITHUB_SHA::12}"; tags="latest-main $version" ;;
refs/tags/v*)
version="${GITHUB_REF#refs/tags/v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Not a vX.Y.Z tag: no image"; exit 0; }
tags="$version ${version%.*} latest" ;;
*) echo "Not main or a tag: no image"; exit 0 ;;
esac
echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u deploy --password-stdin
docker build -f "$DOCKERFILE" --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label org.opencontainers.image.revision="$GITHUB_SHA" --label org.opencontainers.image.version="$version" \
$(for t in $tags; do echo "-t $IMAGE:$t"; done) .
for t in $tags; do docker push -q "$IMAGE:$t"; done
docker logout git.t5de.dev
+2 -1
View File
@@ -26,4 +26,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
(`t5de-proxy...`). (`t5de-proxy...`).
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no - On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
`bunx`: use `bun x`). `bunx`: use `bun x`).
- Calls to IMVU (`apps/api/src/imvu.ts`, its public user API) are rare and rate-limited: linking and age re-checks. - Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms,
and the IMVU products T5 products derive from (`parent_info`, looked up once per revision).
+6
View File
@@ -17,6 +17,9 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge. use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge.
- **Products** (website): browsing and search, product pages, uploads straight from the browser to a catalog,
editing, revisions (retract, stay on one), the library, and registering catalogs (`docs/products.md`). Pages send
a strict Content Security Policy (`apps/web/proxy.ts`).
- **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log. - **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log.
## Layout ## Layout
@@ -46,4 +49,7 @@ Needs Bun (1.4) and Node (for Next.js).
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
public address) and `WEB_URL` (the website's). public address) and `WEB_URL` (the website's).
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`. T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
+3 -1
View File
@@ -5,7 +5,9 @@ COPY package.json bun.lock ./
COPY apps/api/package.json apps/api/ COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile --production --filter @t5de/api # Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated
# layout keeps links in each package's own node_modules.
RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted
FROM oven/bun:1.4.4-slim FROM oven/bun:1.4.4-slim
WORKDIR /app WORKDIR /app
+4 -1
View File
@@ -14,10 +14,13 @@
"dependencies": { "dependencies": {
"@t5de/db": "workspace:*", "@t5de/db": "workspace:*",
"better-auth": "1.7.14", "better-auth": "1.7.14",
"elysia": "1.4.30" "elysia": "1.4.30",
"jose": "6.2.14",
"sanitize-html": "2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.4", "@types/bun": "^1.4.4",
"@types/sanitize-html": "^2.16.2",
"typescript": "^7.0.2" "typescript": "^7.0.2"
} }
} }
+35
View File
@@ -0,0 +1,35 @@
// Age from IMVU itself: an IMVU account that IMVU has age verified, or that has Access Pass, counts as adult. Read
// from IMVU's public API; IMVU can change it (verification revoked, Access Pass lapsed), so it's checked again
// about daily.
import type { ImvuClient } from "../imvu";
import type { AgeResult, Provider, Subject } from "./index";
const recheckAfter = 24 * 60 * 60 * 1000;
export function imvuAgeProvider(imvu: ImvuClient): Provider {
const check = async (subject: Subject): Promise<AgeResult> => {
if (subject.imvuId === undefined) throw new Error("IMVU checks an IMVU account");
const u = await imvu.byId(subject.imvuId);
return {
overEighteen: u.ageVerified || u.accessPass,
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
expiresAt: new Date(Date.now() + recheckAfter),
};
};
return {
id: "imvu",
label: "IMVU's age verification or Access Pass",
scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check,
};
}
/** The IMVU result from a lookup already made (when linking). */
export function imvuResult(u: { ageVerified: boolean; accessPass: boolean }): AgeResult {
return {
overEighteen: u.ageVerified || u.accessPass,
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
expiresAt: new Date(Date.now() + recheckAfter),
};
}
+197
View File
@@ -0,0 +1,197 @@
// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface,
// results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
// See docs/design.md, "Age".
import { and, eq, gt, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
const { ageCheck } = schema;
/** What's checked: a person (all their IMVU accounts), or one of their linked IMVU accounts. */
export type Subject = { userId: string; imvuId?: number };
/** A provider's answer. Only the outcome: never a birth date, document, photo or name. */
export type AgeResult = {
overEighteen: boolean;
/** How it was established, e.g. "imvu_age_verified", "id_document", "wallet_mdoc". */
method: string;
/** The provider's session or transaction ID, if it has one. */
ref?: string;
/** When the answer should be checked again (providers whose answer can change); none: it stands. */
expiresAt?: Date;
};
/** How a check begins. */
export type Started =
| { kind: "done"; result: AgeResult } // answered at once (IMVU)
| { kind: "redirect"; url: string; ref: string } // send the person to the provider's page (document checks)
| { kind: "browser"; request: unknown; ref: string }; // hand a request to the browser (Digital Credentials API)
export type Provider = {
id: string;
/** Shown to people. */
label: string;
/** Whether it checks a person, or an IMVU account. */
scope: "person" | "imvu_account";
start(subject: Subject, options: { returnURL: string }): Promise<Started>;
/** Finishes a check from the provider's webhook; throws if the request isn't really from the provider. */
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" }>;
/** Finishes a check the browser completed (wallets): the browser's response for ref. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */
recheck?(subject: Subject): Promise<AgeResult>;
};
export class AgeError extends Error {}
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
export class AgeService {
private providers = new Map<string, Provider>();
constructor(
private readonly db: Database,
providers: Provider[],
) {
for (const p of providers) this.providers.set(p.id, p);
}
/** The providers in use, for the website. */
list() {
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope }));
}
provider(id: string): Provider {
const p = this.providers.get(id);
if (!p) throw new AgeError(`no age provider ${id}`);
return p;
}
/** Records a finished check. An IMVU account's check replaces that provider's earlier check of it. */
async record(subject: Subject, provider: string, result: AgeResult) {
const now = new Date();
const values = {
userId: subject.userId,
imvuId: subject.imvuId ?? null,
provider,
method: result.method,
status: "complete",
overEighteen: result.overEighteen,
providerRef: result.ref ?? null,
checkedAt: now,
expiresAt: result.expiresAt ?? null,
};
if (subject.imvuId !== undefined) {
await this.db
.delete(ageCheck)
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.imvuId, subject.imvuId)));
}
await this.db.insert(ageCheck).values({ id: randomID(), ...values });
}
/** Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. */
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
const p = this.provider(providerId);
const started = await p.start(subject, { returnURL });
if (started.kind === "done") {
await this.record(subject, p.id, started.result);
} else {
await this.db.insert(ageCheck).values({
id: randomID(),
userId: subject.userId,
imvuId: subject.imvuId ?? null,
provider: p.id,
status: "pending",
providerRef: started.ref,
});
}
return started;
}
/** Finishes a pending check (from a webhook or the browser). */
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
const done =
result === "failed"
? { status: "failed", overEighteen: false, checkedAt: new Date() }
: {
status: "complete",
overEighteen: result.overEighteen,
method: result.method,
checkedAt: new Date(),
expiresAt: result.expiresAt ?? null,
};
const rows = await this.db
.update(ageCheck)
.set(done)
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref), eq(ageCheck.status, "pending")))
.returning();
if (rows.length === 0) throw new AgeError("no such pending check");
return rows[0]!;
}
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
async forgetAccount(imvuId: number) {
await this.db.delete(ageCheck).where(eq(ageCheck.imvuId, imvuId));
}
/**
* The one rule: which of a person's IMVU accounts count as adult. An account does if the person has a current
* complete check saying over 18, or the account itself has one.
*/
async adultAccounts(userId: string, imvuIds: number[]): Promise<Set<number>> {
const now = new Date();
const checks = await this.db
.select({ imvuId: ageCheck.imvuId })
.from(ageCheck)
.where(
and(
eq(ageCheck.userId, userId),
eq(ageCheck.status, "complete"),
eq(ageCheck.overEighteen, true),
or(isNull(ageCheck.expiresAt), gt(ageCheck.expiresAt, now)),
imvuIds.length ? or(isNull(ageCheck.imvuId), inArray(ageCheck.imvuId, imvuIds)) : isNull(ageCheck.imvuId),
),
);
if (checks.some((c) => c.imvuId === null)) return new Set(imvuIds);
return new Set(checks.map((c) => c.imvuId!).filter((id) => imvuIds.includes(id)));
}
/** The latest complete check of each of these IMVU accounts by a provider (for showing how they stand). */
async accountChecks(provider: string, imvuIds: number[]) {
if (imvuIds.length === 0) return new Map<number, typeof ageCheck.$inferSelect>();
const rows = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.status, "complete"), inArray(ageCheck.imvuId, imvuIds)));
return new Map(rows.map((r) => [r.imvuId!, r]));
}
/** The person's own checks (not of an IMVU account), newest first. */
async personChecks(userId: string) {
const rows = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.userId, userId), isNull(ageCheck.imvuId)));
return rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Checks again the IMVU-account checks that expired (at most limit), with providers that can re-check. */
async recheckDue(limit = 50): Promise<number> {
const due = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.status, "complete"), lte(ageCheck.expiresAt, new Date())))
.limit(limit);
let n = 0;
for (const c of due) {
const p = this.providers.get(c.provider);
if (!p?.recheck) continue;
const subject = { userId: c.userId, imvuId: c.imvuId ?? undefined };
try {
await this.record(subject, p.id, await p.recheck(subject));
n++;
} catch {
// Try again next time; the old answer stays expired (not adult) meanwhile.
}
}
return n;
}
}
+984 -44
View File
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
export const maxDecorations = 300;
export const maxDecorationPlacement = 200;
export const maxBatch = 20;
/** An IMVU room instance: "<owner's customer ID>-<n>". */
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
/** An error for the person; the routes turn it into a Problem. */
export class DecorationError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const decorationHash = (contents: DecorationItem[]) =>
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
function ownerOf(instance: string): number {
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
const owner = Number(instance.slice(0, instance.indexOf("-")));
if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase)
throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
return owner;
}
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
export class Decorations {
constructor(private readonly db: Database) {}
/**
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
* retracted, and general unless the viewer counts as adult.
*/
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: product.id })
.from(product)
.where(
and(
inArray(product.id, ids),
ne(product.latest, 0),
isNull(product.removedAt),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
);
return new Set(rows.map((r) => r.id));
}
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
for (const i of instances) ownerOf(i);
const rows = instances.length
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
: [];
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
return instances.map((instance) => {
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
return { instance, contents, hash: decorationHash(contents) };
});
}
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
const owner = ownerOf(instance);
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
if (contents.length > maxDecorations)
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
if (new Set(contents.map((it) => it[0])).size !== contents.length)
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
const ids = [...new Set(contents.map((it) => it[1]))];
if (ids.length) {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
return;
}
await this.db
.insert(roomDecoration)
.values({ instanceId: instance, ownerImvuId: owner, contents })
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
}
}
+437
View File
@@ -0,0 +1,437 @@
// 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, and (phase 2) their moderators, bans,
// images, and people's favorites, ratings and visits.
//
// A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they
// aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused.
import { createHash } from "node:crypto";
import {
and,
avg,
count,
desc,
eq,
gt,
inArray,
isNull,
notInArray,
or,
sql,
schema,
type Database,
type RoomModerator,
type SceneItem,
} from "@t5de/db";
import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
const { hostedRoom, hostedRoomBan, hostedRoomFavorite, hostedRoomImage, hostedRoomRating, hostedRoomVisit, imvuLink, user } =
schema;
export type HostedRoom = typeof hostedRoom.$inferSelect;
export type Rating = "general" | "adult";
export type Privacy = "public" | "private";
/** An error for the person; the routes turn it into a Problem. */
export class HostedError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const maxRoomsPerPerson = 10;
export const maxSceneItems = 500;
export const maxPlacement = 200;
export const maxModerators = 20;
export const maxImageBytes = 512 * 1024;
const visitsKept = 50;
const recentShown = 20;
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
// "<owner's IMVU ID>-<hostedBase + n>": the client takes a room's owner from the first part (so the owner can
// decorate it), and IMVU's own room numbers are far below hostedBase.
export const instanceId = (owner: number, n: number) => `${owner}-${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;
}
/** An image's content type by its magic bytes: PNG or JPEG only. */
export function imageType(b: Uint8Array): "image/png" | "image/jpeg" | undefined {
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (b.length > 24 && png.every((v, i) => b[i] === v)) return "image/png";
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
return undefined;
}
export type NewRoom = {
name: string;
description?: string;
roomPid: number;
rating: Rating;
maxUsers?: number;
imvuId: number;
privacy?: Privacy;
};
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers" | "privacy">> & {
/** Already resolved to accounts (app.ts resolves names). */
moderators?: RoomModerator[];
};
type Row = { room: HostedRoom; ownerName: string };
/** Who's looking: the person and their linked IMVU accounts (for private rooms they moderate). */
export type Viewer = { id: string; imvuIds: number[] };
export const isRoomModerator = (r: HostedRoom, imvuIds: Iterable<number>) => {
const ids = new Set(imvuIds);
return r.moderators.some((m) => ids.has(m.imvuId));
};
export class HostedRooms {
constructor(private readonly db: Database) {}
// 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`;
/** Public rooms, and private ones the viewer owns or moderates. */
private listable(viewer?: Viewer) {
if (!viewer) return eq(hostedRoom.privacy, "public");
const mods = viewer.imvuIds.length
? sql`exists (select 1 from jsonb_array_elements(${hostedRoom.moderators}) m where (m->>'imvuId')::bigint in (${sql.join(
viewer.imvuIds.map((id) => sql`${id}`),
sql`, `,
)}))`
: undefined;
return or(eq(hostedRoom.privacy, "public"), eq(hostedRoom.ownerId, viewer.id), mods);
}
/**
* Open rooms the viewer may see: listed ones (public, and private ones they own or moderate), newest first; or
* (mine) their own; (favorites) their favorites, latest first; (recent) the last 20 they joined, latest first.
*/
async list({
adult,
viewer,
mine,
favorites,
recent,
}: {
adult: boolean;
viewer?: Viewer;
mine?: boolean;
favorites?: boolean;
recent?: boolean;
}): Promise<Row[]> {
const open = and(this.visible, eq(hostedRoom.closed, false), adult || mine ? undefined : eq(hostedRoom.rating, "general"));
if (viewer && favorites) {
return this.shown()
.innerJoin(hostedRoomFavorite, and(eq(hostedRoomFavorite.n, hostedRoom.n), eq(hostedRoomFavorite.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomFavorite.createdAt), desc(hostedRoom.n))
.limit(200);
}
if (viewer && recent) {
return this.shown()
.innerJoin(hostedRoomVisit, and(eq(hostedRoomVisit.n, hostedRoom.n), eq(hostedRoomVisit.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomVisit.at))
.limit(recentShown);
}
return this.shown()
.where(and(open, mine && viewer ? eq(hostedRoom.ownerId, viewer.id) : this.listable(viewer)))
.orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n))
.limit(200);
}
/** 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): Promise<Row> {
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
if (!r) throw new HostedError(404, "not_found", "No such room.");
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
throw new HostedError(404, "not_found", "No such room.");
return r;
}
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
async info(n: number): Promise<HostedRoomInfo | undefined> {
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
return r ? roomInfo(r) : undefined;
}
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,
privacy: body.privacy ?? "public",
scene: [],
sceneHash: sceneHash(body.roomPid, []),
})
.returning();
return { room: r!, ownerName: link.username };
});
}
/** The owner's room, open (for changes). */
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;
let moderators: RoomModerator[] | undefined;
if (changes.moderators) {
const seen = new Set<number>([r.ownerImvuId]); // the owner isn't listed as a moderator of their own room
moderators = changes.moderators.filter((m) => !seen.has(m.imvuId) && seen.add(m.imvuId));
if (moderators.length > maxModerators)
throw new HostedError(400, "invalid", `A room has up to ${maxModerators} moderators.`);
}
const [updated] = await this.db
.update(hostedRoom)
.set({
...(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 } : {}),
...(changes.privacy !== undefined ? { privacy: changes.privacy } : {}),
...(moderators !== undefined ? { moderators } : {}),
...(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;
}
// ---- Bans ----
/** A room the viewer owns or moderates (for its bans). */
async managed(viewer: Viewer, n: number) {
const r = await this.get(n, true, viewer.id);
if (r.room.ownerId !== viewer.id && !isRoomModerator(r.room, viewer.imvuIds))
throw new HostedError(403, "forbidden", "Only the room's owner and moderators can do this.");
return r;
}
async ban(n: number, imvuId: number, byImvuId: number, minutes: number) {
const until = minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
await this.db
.insert(hostedRoomBan)
.values({ n, imvuId, byImvuId, until })
.onConflictDoUpdate({ target: [hostedRoomBan.n, hostedRoomBan.imvuId], set: { byImvuId, until, createdAt: new Date() } });
}
private inForce = () => or(isNull(hostedRoomBan.until), gt(hostedRoomBan.until, new Date()));
async banned(n: number, imvuId: number): Promise<boolean> {
const [b] = await this.db
.select({ n: hostedRoomBan.n })
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId), this.inForce()));
return b !== undefined;
}
/** A room's bans in force, latest first. */
async bans(n: number) {
return this.db
.select()
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), this.inForce()))
.orderBy(desc(hostedRoomBan.createdAt));
}
/** Lifts a ban (owner only); false if there was none. */
async unban(ownerId: string, n: number, imvuId: number): Promise<boolean> {
await this.own(ownerId, n);
const gone = await this.db
.delete(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId)))
.returning();
return gone.length > 0;
}
// ---- Images ----
async setImage(ownerId: string, n: number, data: Buffer) {
const { ownerName } = await this.own(ownerId, n);
if (data.length > maxImageBytes) throw new HostedError(400, "invalid", "An image is up to 512 KB.");
const contentType = imageType(data);
if (!contentType) throw new HostedError(400, "invalid", "An image is a PNG or JPEG.");
const hash = createHash("sha256").update(data).digest("hex");
const room = await this.db.transaction(async (tx) => {
await tx
.insert(hostedRoomImage)
.values({ n, contentType, data, hash })
.onConflictDoUpdate({ target: hostedRoomImage.n, set: { contentType, data, hash, updatedAt: new Date() } });
const [r] = await tx.update(hostedRoom).set({ imageHash: hash }).where(eq(hostedRoom.n, n)).returning();
return r!;
});
return { room, ownerName };
}
async removeImage(ownerId: string, n: number) {
const { ownerName } = await this.own(ownerId, n);
await this.db.delete(hostedRoomImage).where(eq(hostedRoomImage.n, n));
const [room] = await this.db.update(hostedRoom).set({ imageHash: null }).where(eq(hostedRoom.n, n)).returning();
return { room: room!, ownerName };
}
async image(n: number) {
const [i] = await this.db.select().from(hostedRoomImage).where(eq(hostedRoomImage.n, n));
return i;
}
// ---- Favorites, ratings, visits ----
async favorite(userId: string, n: number, on: boolean) {
if (on) await this.db.insert(hostedRoomFavorite).values({ userId, n }).onConflictDoNothing();
else await this.db.delete(hostedRoomFavorite).where(and(eq(hostedRoomFavorite.userId, userId), eq(hostedRoomFavorite.n, n)));
}
/** Which of these rooms are the person's favorites. */
async favoriteSet(userId: string, ns: number[]): Promise<Set<number>> {
if (ns.length === 0) return new Set();
const rows = await this.db
.select({ n: hostedRoomFavorite.n })
.from(hostedRoomFavorite)
.where(and(eq(hostedRoomFavorite.userId, userId), inArray(hostedRoomFavorite.n, ns)));
return new Set(rows.map((r) => r.n));
}
/** Sets (1-5) or removes (null) the person's rating. */
async rate(userId: string, n: number, score: number | null) {
if (score === null) {
await this.db.delete(hostedRoomRating).where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return;
}
await this.db
.insert(hostedRoomRating)
.values({ userId, n, score })
.onConflictDoUpdate({ target: [hostedRoomRating.userId, hostedRoomRating.n], set: { score, updatedAt: new Date() } });
}
async myRating(userId: string, n: number): Promise<number> {
const [r] = await this.db
.select({ score: hostedRoomRating.score })
.from(hostedRoomRating)
.where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return r?.score ?? 0;
}
/** Each room's average rating (0 if none, two decimals) and how many rated it. */
async scores(ns: number[]): Promise<Map<number, { score: number; ratings: number }>> {
if (ns.length === 0) return new Map();
const rows = await this.db
.select({ n: hostedRoomRating.n, score: avg(hostedRoomRating.score), ratings: count() })
.from(hostedRoomRating)
.where(inArray(hostedRoomRating.n, ns))
.groupBy(hostedRoomRating.n);
return new Map(rows.map((r) => [r.n, { score: Math.round(Number(r.score ?? 0) * 100) / 100, ratings: r.ratings }]));
}
/** Records a visit (a join), keeping the person's latest 50. */
async visit(userId: string, n: number, imvuId: number) {
const at = new Date();
await this.db
.insert(hostedRoomVisit)
.values({ userId, n, imvuId, at })
.onConflictDoUpdate({ target: [hostedRoomVisit.userId, hostedRoomVisit.n], set: { imvuId, at } });
const keep = this.db
.select({ n: hostedRoomVisit.n })
.from(hostedRoomVisit)
.where(eq(hostedRoomVisit.userId, userId))
.orderBy(desc(hostedRoomVisit.at))
.limit(visitsKept);
await this.db.delete(hostedRoomVisit).where(and(eq(hostedRoomVisit.userId, userId), notInArray(hostedRoomVisit.n, keep)));
}
}
/** What realtime needs of a room. */
export function roomInfo({ room: r, ownerName }: Row): HostedRoomInfo {
return {
closed: r.closed,
adult: r.rating === "adult",
maxUsers: r.maxUsers,
privacy: r.privacy === "private" ? "private" : "public",
ownerImvuId: r.ownerImvuId,
moderators: r.moderators.map((m) => m.imvuId),
name: r.name,
ownerName,
};
}
+51
View File
@@ -0,0 +1,51 @@
// Product descriptions: HTML, as on IMVU, made safe by an allowlist (docs/design.md, "Product info"). Cleaned when
// saved; whatever shows them cleans again.
import sanitize from "sanitize-html";
export const maxDescription = 20_000;
const color = [/^#[0-9a-f]{3,8}$/i, /^[a-z]{3,20}$/i, /^rgba?\(\s*[\d.\s,%]+\)$/i];
const size = [/^\d{1,3}(\.\d+)?(px|pt|em|rem|%)$/, /^(xx-small|x-small|small|medium|large|x-large|xx-large)$/];
const options: sanitize.IOptions = {
allowedTags: [
"b", "i", "u", "s", "em", "strong", "small", "big", "sub", "sup", "font", "span", "div", "p", "br", "hr",
"center", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6",
"a", "img",
],
allowedAttributes: {
a: ["href", "rel", "target"], // rel and target are always set by the transform below
img: ["src", "alt", "width", "height"],
font: ["color", "size"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan"],
"*": ["style"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: { img: ["https"] },
allowProtocolRelative: false,
allowedStyles: {
"*": {
color,
"background-color": color,
"font-size": size,
"font-weight": [/^(normal|bold|bolder|lighter|[1-9]00)$/],
"font-style": [/^(normal|italic|oblique)$/],
"text-align": [/^(left|right|center|justify)$/],
"text-decoration": [/^(none|underline|line-through|overline)$/],
},
},
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
}),
},
// Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped.
exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""),
disallowedTagsMode: "discard",
};
export function cleanHTML(html: string): string {
return sanitize(html, options).trim();
}
+114 -7
View File
@@ -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. // rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
export type ImvuUser = { export type ImvuUser = {
id: number; id: number;
@@ -7,6 +8,29 @@ export type ImvuUser = {
tagline: string; tagline: string;
ageVerified: boolean; ageVerified: boolean;
accessPass: boolean; accessPass: boolean;
picture: string | null; // the avatar's picture (https, on *.imvu.com)
};
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;
};
/** An IMVU product's public info, as T5 products derived from it show it. */
export type ImvuProduct = {
image: string | null; // https, on *.imvu.com
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
gender: string; // "female", "male", or "" (either)
}; };
export class NoSuchImvuUser extends Error { export class NoSuchImvuUser extends Error {
@@ -31,11 +55,36 @@ 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;
}
}
/**
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
*/
async product(id: number): Promise<ImvuProduct | null> {
if (!Number.isSafeInteger(id) || id <= 0) return null;
try {
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
} catch (e) {
if (e instanceof NoSuchImvuUser) return null;
throw e;
}
} }
private async wait() { private async wait() {
@@ -45,7 +94,8 @@ 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> { /** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
private async get(path: string, missing: number[] = []): Promise<unknown> {
await this.wait(); await this.wait();
let response: Response; let response: Response;
try { try {
@@ -56,12 +106,68 @@ export class ImvuClient {
} catch (e) { } catch (e) {
throw new ImvuUnavailable(String(e)); throw new ImvuUnavailable(String(e));
} }
if (response.status === 404) throw new NoSuchImvuUser(); if (response.status === 404 || missing.includes(response.status)) 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,
};
}
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
export function imvuImage(v: unknown): string | null {
if (typeof v !== "string" || v.length > 1000) return null;
let url: URL;
try {
url = new URL(v.startsWith("//") ? "https:" + v : v);
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
return url.href;
}
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
const d = entry?.[1].data;
if (!d) return null;
const path = Array.isArray(d.category_path) ? d.category_path : [];
const categories = path.slice(0, 10).flatMap((c: unknown) => {
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
return [{ id: n, name: name.trim().slice(0, 100) }];
});
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
return { image: imvuImage(d.product_image), categories, gender };
}
// 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 ?? {};
@@ -77,5 +183,6 @@ export function parse(answer: unknown): ImvuUser {
tagline: typeof data.tagline === "string" ? data.tagline : "", tagline: typeof data.tagline === "string" ? data.tagline : "",
ageVerified: data.is_ageverified === true, ageVerified: data.is_ageverified === true,
accessPass: data.is_ap === true, accessPass: data.is_ap === true,
picture: imvuImage(data.thumbnail_url),
}; };
} }
+11 -1
View File
@@ -22,6 +22,16 @@ 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 } = createApp({ db, auth, imvu: new ImvuClient() }); const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
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; revisions missing their
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
parentInfo();
setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo();
}, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+72
View File
@@ -0,0 +1,72 @@
// The hub's signing key (Ed25519) for the tokens catalogs and proxies check: upload, access and revision tokens
// (docs/products.md). Made on first use and kept in the database; the public half is at
// /.well-known/t5de-keys.json.
import { desc, schema, type Database } from "@t5de/db";
import { exportJWK, generateKeyPair, importJWK, jwtVerify, SignJWT, type JWK, type JWTPayload } from "jose";
const { signingKey } = schema;
type Key = { kid: string; publicJWK: JWK; privateKey: CryptoKey };
export class Signer {
private key?: Promise<Key>;
constructor(
private readonly db: Database,
private readonly issuer: string,
) {}
private load(): Promise<Key> {
this.key ??= (async () => {
const [row] = await this.db.select().from(signingKey).orderBy(desc(signingKey.createdAt)).limit(1);
if (row) {
return {
kid: row.id,
publicJWK: row.publicKey,
privateKey: (await importJWK(row.privateKey, "EdDSA")) as CryptoKey,
};
}
const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
const publicJWK = await exportJWK(pair.publicKey);
const kid = Buffer.from(crypto.getRandomValues(new Uint8Array(9))).toString("base64url");
await this.db.insert(signingKey).values({
id: kid,
publicKey: publicJWK as Record<string, string>,
privateKey: (await exportJWK(pair.privateKey)) as Record<string, string>,
});
return { kid, publicJWK, privateKey: pair.privateKey };
})();
return this.key;
}
/** A token of type typ with these claims; expiresIn in seconds (none: no expiry). */
async sign(typ: string, claims: Record<string, unknown>, expiresIn?: number): Promise<string> {
const k = await this.load();
let jwt = new SignJWT({ ...claims, typ })
.setProtectedHeader({ alg: "EdDSA", kid: k.kid, typ: "JWT" })
.setIssuer(this.issuer)
.setIssuedAt();
if (expiresIn) jwt = jwt.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn);
return jwt.sign(k.privateKey);
}
/** A token's claims, if the hub signed it, it's of type typ, and it hasn't expired; else undefined. */
async verify(token: string, typ: string): Promise<JWTPayload | undefined> {
const k = await this.load();
try {
const { payload } = await jwtVerify(token, await importJWK(k.publicJWK, "EdDSA"), {
issuer: this.issuer,
algorithms: ["EdDSA"],
});
return payload.typ === typ ? payload : undefined;
} catch {
return undefined;
}
}
/** The public keys, as a JWKS. */
async jwks() {
const k = await this.load();
return { keys: [{ ...k.publicJWK, kid: k.kid, alg: "EdDSA", use: "sig" }] };
}
}
+492
View File
@@ -0,0 +1,492 @@
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
import { createHash, timingSafeEqual } from "node:crypto";
import {
and,
desc,
eq,
gt,
ilike,
inArray,
isNull,
ne,
or,
sql,
schema,
type Database,
type FileEntry,
type ParentInfo,
} from "@t5de/db";
import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys";
const { catalog, product, productRevision, hosting, library, user } = schema;
export type Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect;
export type Revision = typeof productRevision.$inferSelect;
/** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
const base32 = "abcdefghijklmnopqrstuvwxyz234567";
export const newProductID = () =>
[...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join("");
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const nameRule = (s: unknown, max: number) => {
const v = typeof s === "string" ? s.trim() : "";
// eslint-disable-next-line no-control-regex
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
return v;
};
/** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
const out: FileEntry[] = [];
const names = new Set<string>();
for (const f of files) {
const name = typeof f?.name === "string" ? f.name : "";
const hash = typeof f?.sha256 === "string" ? f.sha256 : "";
const size = Number(f?.size);
if (!name || name.length > 200 || names.has(name) || /[/\\]/.test(name)) return undefined;
if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(size) || size < 0) return undefined;
names.add(name);
out.push({ name, sha256: hash, size });
}
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
// Field by field: stored lists come back from JSONB with their keys in another order.
const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
export class Products {
constructor(
private readonly db: Database,
private readonly signer: Signer,
private readonly imvu?: ImvuClient,
) {}
// ---- catalogs ----
async registerCatalog(
ownerId: string,
input: { name: string; url: string; contact: string; visibility: string; membership: string },
) {
const name = nameRule(input.name, 64);
const contact = nameRule(input.contact, 200);
let url: URL;
try {
url = new URL(input.url);
} catch {
throw new ProductError(400, "invalid", "That address isn't a URL.");
}
if (!name || !contact) throw new ProductError(400, "invalid", "A name and a takedown contact are needed.");
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
throw new ProductError(400, "invalid", "Catalogs need an https address.");
if (!["public", "private"].includes(input.visibility) || !["open", "approval", "invite"].includes(input.membership))
throw new ProductError(400, "invalid", "Choose a visibility and a membership.");
const clean = url.origin + url.pathname.replace(/\/+$/, "");
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, clean));
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
const key = randomKey();
const [c] = await this.db
.insert(catalog)
.values({
id: newProductID(),
ownerId,
name,
url: clean,
contact,
visibility: input.visibility,
membership: input.membership,
keyHash: sha256(key),
})
.returning();
return { catalog: c!, key };
}
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */
async catalogFor(header: string | null): Promise<Catalog | undefined> {
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
if (!m) return undefined;
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
if (!c || c.status !== "approved") return undefined;
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
}
async catalogs(viewerId?: string) {
const rows = await this.db
.select()
.from(catalog)
.where(
or(
and(eq(catalog.status, "approved"), eq(catalog.visibility, "public")),
viewerId ? eq(catalog.ownerId, viewerId) : sql`false`,
),
)
.orderBy(catalog.name);
return rows;
}
async allCatalogs() {
return this.db.select().from(catalog).orderBy(desc(catalog.createdAt));
}
async setCatalogStatus(id: string, status: "approved" | "rejected") {
const [c] = await this.db.update(catalog).set({ status }).where(eq(catalog.id, id)).returning();
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
return c;
}
// ---- uploads ----
/** An upload token for a person to send a product (or a new revision of theirs) to a catalog. */
async uploadToken(personId: string, catalogId: string, productId?: string) {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
if (productId) await this.own(personId, productId);
const token = await this.signer.sign(
"t5de-upload",
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
30 * 60,
);
return { token, url: `${c.url}/api/v1/uploads` };
}
/** A catalog registers an uploaded revision: a new product, or a new revision of one. */
async registerRevision(
c: Catalog,
body: { uploadToken: string; files: unknown; rating: string; parent: number; name: string; notes: string },
) {
const claims = await this.signer.verify(body.uploadToken, "t5de-upload");
if (!claims || claims.catalog !== c.id || typeof claims.sub !== "string")
throw new ProductError(401, "bad_token", "The upload token isn't valid for this catalog.");
const files = checkFiles(body.files);
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
if (body.rating !== "general" && body.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub;
const made = await this.db.transaction(async (tx) => {
let p: Product;
if (typeof claims.product === "string") {
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
if (!found || found.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (!notes) throw new ProductError(400, "invalid", "Say what changed in this revision.");
const [latest] = await tx
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, found.id), eq(productRevision.number, found.latest)));
if (latest && sameFiles(latest.files, files))
throw new ProductError(409, "unchanged", "These files are the same as the latest revision's.");
p = found;
} else {
const name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx
.insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
.returning();
p = made!;
}
const number = p.latest + 1;
const token = await this.signer.sign("t5de-revision", {
product: p.id,
revision: number,
rating: body.rating,
files,
parent,
});
await tx.insert(productRevision).values({
productId: p.id,
number,
files,
rating: body.rating,
parent,
notes,
token,
uploadedBy: personId,
});
// A product is adult if any revision is (or its creator says so).
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number };
});
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
return made;
}
// ---- parents (the IMVU products revisions derive from) ----
/** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */
async lookUpParent(productId: string, number: number, parent: number): Promise<ParentInfo | undefined> {
if (!this.imvu || parent <= 0) return undefined;
const since = new Date(Date.now() - parentInfoFresh).toISOString();
const [known] = await this.db
.select({ info: productRevision.parentInfo })
.from(productRevision)
.where(
and(
eq(productRevision.parent, parent),
sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`,
),
)
.limit(1);
let info = known?.info ?? undefined;
if (!info) {
const p = await this.imvu.product(parent);
info = {
found: p !== null,
image: p?.image ?? null,
categories: p?.categories ?? [],
gender: p?.gender ?? "",
checkedAt: new Date().toISOString(),
};
}
await this.db
.update(productRevision)
.set({ parentInfo: info })
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
return info;
}
/** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */
async fillParentInfo(limit = 20): Promise<number> {
const due = await this.db
.select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent })
.from(productRevision)
.where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo)))
.orderBy(desc(productRevision.createdAt))
.limit(limit);
let n = 0;
for (const r of due) {
try {
if (await this.lookUpParent(r.productId, r.number, r.parent)) n++;
} catch {
// IMVU unavailable: try again next time.
}
}
return n;
}
// ---- products ----
private async own(personId: string, productId: string) {
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
if (!p) throw new ProductError(404, "not_found", "No such product.");
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
async delete(personId: string, id: string) {
await this.own(personId, id);
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
}
/** Moderation removes a product: gone everywhere. */
async removeByModeration(id: string, reason: string) {
const [p] = await this.db
.update(product)
.set({ removedAt: new Date(), removedReason: reason })
.where(and(eq(product.id, id), isNull(product.removedAt)))
.returning();
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
return p;
}
/**
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
*/
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
if (!opts.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
if (opts.category !== undefined)
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.createdAt))
.limit(limit);
}
/** A product's latest revision's parent and parent info (for views of one product). */
async latestParent(p: Product) {
const [r] = await this.db
.select({ parent: productRevision.parent, parentInfo: productRevision.parentInfo })
.from(productRevision)
.where(and(eq(productRevision.productId, p.id), eq(productRevision.number, p.latest)));
return r;
}
async get(id: string, adult: boolean) {
const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
.from(productRevision)
.where(eq(productRevision.productId, id))
.orderBy(desc(productRevision.number));
const hosts = await this.db
.select({ number: hosting.number, name: catalog.name, url: catalog.url })
.from(hosting)
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
.where(and(eq(hosting.productId, id), eq(catalog.status, "approved")));
return { ...row, revisions, hosts };
}
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
const p = await this.own(personId, id);
const set: Partial<Product> = {};
if (input.name !== undefined) {
const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
}
if (input.description !== undefined) {
if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long.");
set.description = cleanHTML(input.description);
}
if (input.rating !== undefined) {
if (input.rating === "adult") set.rating = "adult";
else if (input.rating === "general" && p.rating === "adult")
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
}
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
return updated!;
}
async retract(personId: string, id: string, number: number) {
await this.own(personId, id);
const [r] = await this.db
.update(productRevision)
.set({ retracted: true })
.where(and(eq(productRevision.productId, id), eq(productRevision.number, number)))
.returning();
if (!r) throw new ProductError(404, "not_found", "No such revision.");
return r;
}
// ---- libraries ----
async library(personId: string) {
return this.db
.select({
product,
stayOn: library.stayOn,
addedAt: library.addedAt,
creator: user.displayUsername,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(library)
.innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
.orderBy(desc(library.addedAt));
}
async inLibrary(personId: string, productId: string) {
const [row] = await this.db
.select({ stayOn: library.stayOn })
.from(library)
.where(and(eq(library.userId, personId), eq(library.productId, productId)));
return row;
}
async add(personId: string, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
}
async remove(personId: string, productId: string) {
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
}
async stayOn(personId: string, productId: string, number: number | null) {
if (number !== null) {
const [r] = await this.db
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
if (!r || r.retracted) throw new ProductError(404, "not_found", "No such revision.");
}
const [row] = await this.db
.update(library)
.set({ stayOn: number })
.where(and(eq(library.userId, personId), eq(library.productId, productId)))
.returning();
if (!row) throw new ProductError(404, "not_found", "That product isn't in your library.");
}
/**
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
if (usable.length === 0) throw new ProductError(404, "not_found", "That product has no usable revision.");
const r =
usable.find((r) => r.number === want) ??
usable.find((r) => r.number < want) ??
usable[usable.length - 1]!;
const hosts = p.hosts.filter((h) => h.number === r.number).map((h) => h.url);
return { product: p.product, creator: p.creator, revision: r, catalogs: hosts };
}
/** People's display names for creators, by ID (for the console). */
async creators(ids: string[]) {
if (!ids.length) return [];
return this.db.select({ id: user.id, username: user.displayUsername }).from(user).where(inArray(user.id, ids));
}
}
+632 -23
View File
@@ -1,33 +1,196 @@
// 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, 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 // 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": "decor", "room": "1-2"}
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
// with a chat in that room
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."} // 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", "name"}],
// "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)
//
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
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[] };
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void }; /** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = {
closed: boolean;
adult: boolean;
maxUsers: number;
privacy: "public" | "private";
ownerImvuId: number;
moderators: number[];
name: string;
ownerName: string;
};
export type Owners = (imvuId: number) => Promise<string | undefined>; /** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: 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; name: string };
// 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. watching: the hosted rooms it
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
type Session = {
userId: string;
imvuId: number;
requested: number;
adult: boolean;
since: Date;
outfit: Item[];
chats: Map<string, string>;
lastSent: Map<string, string>;
hosted: Set<number>;
tokens: number;
refilled: number;
watching: Set<number>;
watchGen: number;
invites: number;
invitesRefilled: 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.
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
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;
/** A hosted room (undefined: there's none by that number). */
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
/** Whether an IMVU account is banned from a hosted room now. */
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
/** Someone was booted (and maybe banned): store the ban, log it. */
hostedBoot?: (boot: Boot) => Promise<void>;
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
accountName?: (imvuId: number) => Promise<string>;
};
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 maxWatched = 100;
const inviteLifetime = 10 * 60 * 1000;
const inviteBurst = 10;
const invitesPerMinute = 10;
const maxNote = 200;
const maxBanMinutes = 10 * 366 * 24 * 60;
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.",
banned: "You're banned from this room.",
private: "This room is private.",
} as const;
// Reasons in room.boot_result and room.invite_result.
const actionErrors = {
not_linked: roomErrors.not_linked,
not_found: roomErrors.not_found,
closed: roomErrors.closed,
forbidden: "You can't do that in this room.",
not_in_room: "Join the room first.",
banned: "They're banned from this room.",
adult: "This room is for adults.",
rate_limited: "Too many invitations. Try again in a minute.",
} as const;
export class Realtime { export class Realtime {
private sessions = new Map<string, Session>(); private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private watchers = new Map<number, Set<string>>();
private invites = new Map<string, Invite>();
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: () => {},
hostedRoom: async () => undefined,
hostedBanned: async () => false,
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
...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();
} }
@@ -38,7 +201,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());
} }
@@ -47,14 +211,80 @@ 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, 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(),
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
watching: new Set(),
watchGen: 0,
invites: inviteBurst,
invitesRefilled: Date.now(),
send,
close,
});
} }
close(id: string) { close(id: string) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (s) {
this.leaveAllHosted(id, s, false);
this.unwatch(id, s);
}
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. */
async linked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const s of this.sessions.values()) {
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
s.imvuId = imvuId;
s.since = new Date();
s.adult = await this.deps.adult(userId, imvuId);
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed(chats);
}
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
unlinked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
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 = [];
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed(chats);
}
/** Sends a message to all of a person's connections. */
notify(userId: string, message: object) {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
}
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
decorated(instance: string) {
const message = { type: "decor", room: instance };
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
} }
/** Closes a person's connections (banned, or signed out of their computers). */ /** Closes a person's connections (banned, or signed out of their computers). */
@@ -62,35 +292,414 @@ 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);
}
}
// ---- 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 and its IMVU name. */
participants(n: number): Participant[] {
const seats = new Map<number, { seat: number; name: string }>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name });
return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name }));
}
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) {
const participants = this.participants(n);
this.toRoom(n, { type: "room.participants", room: n, participants }, except);
const occupancy = { type: "room.occupancy", room: n, participants };
for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy);
}
/** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to
* leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) });
this.participantsChanged(n);
}
private toAccount(imvuId: number, message: object) {
for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message);
}
private unwatch(id: string, s: Session) {
for (const n of s.watching) {
const w = this.watchers.get(n);
w?.delete(id);
if (w?.size === 0) this.watchers.delete(n);
}
s.watching.clear();
}
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 watching = this.watchers.get(n);
if (watching) {
this.watchers.delete(n);
for (const w of watching) {
const s = this.sessions.get(w);
s?.watching.delete(n);
s?.send({ type: "room.occupancy", room: n, participants: [] });
}
}
for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id);
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);
}
}
/** Whether an account is the room's owner account, or one of its moderators. */
private static staff(info: HostedRoomInfo, imvuId: number) {
return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) };
}
private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise<object> {
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);
const name = await this.deps.accountName(imvuId);
const banned = await this.deps.hostedBanned(n, 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");
if (banned) return refuse("banned");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
// A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder.
let admit: Invite | undefined;
const staff = Realtime.staff(info, imvuId);
if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) {
const inv = typeof invite === "string" ? this.invites.get(invite) : undefined;
if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private");
admit = inv;
}
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, admitted: new Set() }));
if (admit) {
this.invites.delete(admit.id); // single use
room.admitted.add(imvuId);
}
room.members.set(id, { imvuId, seat: 0, name });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId);
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */
private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) {
const target = m.imvu_id;
const ban = m.ban === undefined ? false : m.ban;
const minutes = m.minutes === undefined ? 0 : m.minutes;
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
if (typeof ban !== "boolean") return { type: "error", message: "bad ban" };
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes)
return { type: "error", message: "bad minutes" };
const result = (reason?: keyof typeof actionErrors) => ({
type: "room.boot_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }),
});
if (!s.imvuId) return result("not_linked");
const actor = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked");
const me = Realtime.staff(info, actor);
const them = Realtime.staff(info, target);
// Nobody boots the owner; moderators don't boot moderators; nobody boots themselves.
if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor)
return result("forbidden");
await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes });
const room = this.hosted.get(n);
room?.admitted.delete(target);
for (const [sid, member] of [...(room?.members ?? [])]) {
const other = this.sessions.get(sid);
if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted");
}
return result();
}
/** Whether a session may send another invitation now (10, then 10 a minute). */
private allowInvite(s: Session): boolean {
const now = Date.now();
s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute);
s.invitesRefilled = now;
if (s.invites < 1) return false;
s.invites -= 1;
return true;
}
/** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */
private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise<object | undefined> {
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
const result = (reason?: keyof typeof actionErrors, invite?: string) => ({
type: "room.invite_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }),
});
if (!s.imvuId) return result("not_linked");
const from = s.imvuId;
if (target === from) return { type: "error", message: "bad imvu_id" };
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
const banned = await this.deps.hostedBanned(n, target);
if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked");
const me = Realtime.staff(info, from);
if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id))
return result(info.privacy === "private" ? "forbidden" : "not_in_room");
if (banned) return result("banned");
const targets = [...this.sessions.values()].filter((t) => t.imvuId === target);
if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult");
if (!this.allowInvite(s)) return result("rate_limited");
const now = Date.now();
for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k);
const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
s.send(result(undefined, inviteId));
if (targets.length === 0) {
s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true });
return;
}
this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false });
const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName };
for (const t of targets) if (!info.adult || t.adult) t.send(message);
}
/** room.invite_reply: the invited account answers (the first answer counts). */
private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined {
const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined;
if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" };
if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" };
if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" };
if (inv.answered) return { type: "error", message: "already answered" };
inv.answered = true;
inv.accepted = m.accept;
if (!m.accept) this.invites.delete(inv.id);
// eslint-disable-next-line no-control-regex
const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote);
this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) });
}
/** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */
private async watch(id: string, s: Session, rooms: unknown): Promise<object | undefined> {
if (
!Array.isArray(rooms) ||
rooms.length > maxWatched ||
!rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN)
)
return { type: "error", message: "bad rooms" };
const gen = ++s.watchGen;
this.unwatch(id, s);
const ns = [...new Set(rooms as number[])];
const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n)));
if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came
for (const [i, n] of ns.entries()) {
const info = infos[i];
if (!info || info.closed || (info.adult && !s.adult)) continue;
const me = Realtime.staff(info, s.imvuId);
if (info.privacy === "private" && !me.owner && !me.moderator) continue;
s.watching.add(n);
let w = this.watchers.get(n);
if (!w) this.watchers.set(n, (w = new Set()));
w.add(id);
s.send({ type: "room.occupancy", room: n, participants: this.participants(n) });
}
}
/** 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. */ /** 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;
seat?: unknown;
mount?: unknown;
message?: unknown;
invite?: unknown;
ban?: unknown;
minutes?: unknown;
accept?: unknown;
note?: unknown;
rooms?: 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);
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined; 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) { 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") {
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" }; const items = m.items ?? [];
s.outfit = m.items if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
if (items.length > 64) return { type: "error", message: "too many 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;
}
if (m.type === "room.watch") return this.watch(id, s, m.rooms);
if (m.type === "room.invite_reply") return this.inviteReply(s, m);
if (
m.type === "room.join" ||
m.type === "room.leave" ||
m.type === "room.seat" ||
m.type === "room.send" ||
m.type === "room.boot" ||
m.type === "room.invite"
) {
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, m.invite);
if (m.type === "room.boot") return this.bootHosted(id, s, n, m);
if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id);
if (m.type === "room.leave") {
this.leaveHosted(id, s, n, false);
// Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room.
const room = this.hosted.get(n);
if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId);
return;
}
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;
} }
return { type: "error", message: "unknown message type" }; return { type: "error", message: "unknown message type" };
+111
View File
@@ -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));
}
}
+136
View File
@@ -0,0 +1,136 @@
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed
// webhooks), and IMVU's answers changing.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, type Provider } from "../src/age";
import { imvuAgeProvider } from "../src/age/imvu";
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 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;
// A document-check vendor: sends the person to its page, then reports by a webhook signed with a shared secret.
const webhookSecret = "vendor-secret";
const fakeVendor: Provider = {
id: "vendor",
label: "ID document",
scope: "person",
start: async () => {
const ref = `session-${crypto.randomUUID()}`;
return { kind: "redirect", url: `https://vendor.test/check/${ref}`, ref };
},
webhook: async (request) => {
if (request.headers.get("x-signature") !== webhookSecret) throw new Error("bad signature");
const body = (await request.json()) as { session: string; approved: boolean; over18: boolean };
return { ref: body.session, result: body.approved ? { overEighteen: body.over18, method: "id_document" } : "failed" };
},
};
let db: Database;
let close: () => Promise<void>;
let base: string;
let age: AgeService;
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 imvu = new ImvuClient("http://imvu.test", fakeIMVU);
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]);
const hub = createApp({ db, auth, imvu, age });
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();
});
async function call(cookie: { v: string }, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
async function personWithAccounts(name: string, ids: { id: number; ageVerified?: boolean; accessPass?: boolean }[]) {
const cookie = { v: "" };
await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name });
for (const a of ids) {
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass, picture: null });
const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return cookie;
}
const adultOf = async (cookie: { v: string }) =>
Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult]));
test("IMVU's answer is per account, and re-checked when it expires", async () => {
const a = await personWithAccounts("ann", [{ id: 1, ageVerified: true }, { id: 2 }, { id: 3, accessPass: true }]);
expect(await adultOf(a)).toEqual({ 1: true, 2: false, 3: true });
// IMVU revokes account 1's verification; once its answer expires, the re-check sees it.
accounts.get(1)!.ageVerified = false;
await db.update(schema.ageCheck).set({ expiresAt: new Date(Date.now() - 1000) });
expect(await age.recheckDue()).toBeGreaterThanOrEqual(3);
expect(await adultOf(a)).toEqual({ 1: false, 2: false, 3: true });
});
test("a person's check covers all their accounts; webhooks must be signed", async () => {
const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]);
expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor"]);
// IMVU checks accounts, not people: it can't be started this way.
expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400);
const started = (await call(b, "POST", "/api/v1/age/vendor/start", { returnURL: `${webURL}/account` })).json;
expect(started.kind).toBe("redirect");
expect((await call(b, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ provider: "vendor", status: "pending" });
const hook = (signature: string, approved: boolean) =>
call({ v: "" }, "POST", "/api/v1/age/webhook/vendor", { session: started.ref, approved, over18: true }, { "x-signature": signature });
expect((await hook("forged", true)).status).toBe(401);
expect(await adultOf(b)).toEqual({ 10: false, 11: false });
expect((await hook(webhookSecret, true)).status).toBe(204);
expect(await adultOf(b)).toEqual({ 10: true, 11: true });
// A repeated webhook is harmless.
expect((await hook(webhookSecret, true)).status).toBe(204);
});
test("a moved account takes nobody's age with it", async () => {
const c = await personWithAccounts("cal", [{ id: 20, ageVerified: true }]);
expect(await adultOf(c)).toEqual({ 20: true });
// Dee proves the account is hers; IMVU now says it isn't verified (say, the verification was the old owner's).
accounts.get(20)!.ageVerified = false;
const d = { v: "" };
await call(d, "POST", "/api/auth/sign-up/email", { email: "[email protected]", password: "a long password", name: "dee", username: "dee" });
const started = await call(d, "POST", "/api/v1/account/links", { username: "cal20" });
accounts.get(20)!.tagline = started.json.challenge.code;
expect((await call(d, "POST", "/api/v1/account/links/20/check")).json.moved).toBe(true);
expect(await adultOf(d)).toEqual({ 20: false });
const leftovers = await db.select().from(schema.ageCheck);
expect(leftovers.filter((c) => c.imvuId === 20)).toHaveLength(1);
});
+121
View File
@@ -0,0 +1,121 @@
// The T5 badge on people: who shows it, as the proxy asks.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, 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 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<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, proxy = false) {
const auth: Record<string, string> = p ? (proxy ? { Authorization: `Bearer ${p.token}` } : { Cookie: p.cookie }) : {};
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...auth },
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 and links their IMVU accounts. */
async function person(name: string, ...ids: number[]): 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("");
for (const [i, id] of ids.entries()) {
const username = `${name}Av${i}`;
accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200);
}
return p;
}
const badges = async (p: Person | undefined, imvuIds: unknown) => call(p, "POST", "/api/v1/badges", { imvuIds }, true);
test("only linked accounts whose badge is on, of people who aren't banned, get the badge", async () => {
const admin = await person("badgeadmin");
const ann = await person("ann", 301, 302);
const bob = await person("bob", 303);
const asker = await person("asker");
// Off by default.
const account = (await call(ann, "GET", "/api/v1/account")).json;
expect(account.links.map((l: any) => l.showBadge)).toEqual([false, false]);
expect((await badges(asker, [301, 302, 303, 999])).json).toEqual({ badges: [] });
// Turned on for one account; the other stays off.
const patched = await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: true });
expect(patched.json.link.showBadge).toBe(true);
await call(bob, "PATCH", "/api/v1/account/links/303", { showBadge: true });
const got = await badges(asker, [301, 302, 303, 999, 301]);
expect(got.status).toBe(200);
expect(got.json.badges.sort()).toEqual([301, 303]);
expect(Object.keys(got.json)).toEqual(["badges"]);
// Turned off again.
await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: false });
expect((await badges(asker, [301, 303])).json).toEqual({ badges: [303] });
// A banned person's accounts don't.
const bobId = (await call(bob, "GET", "/api/v1/me")).json.person.id;
await db.update(schema.user).set({ banned: true }).where(eq(schema.user.id, bobId));
expect((await badges(asker, [303])).json).toEqual({ badges: [] });
expect((await badges(asker, [])).json).toEqual({ badges: [] });
expect((await badges(admin, [303])).status).toBe(200);
});
test("lookups are validated and need signing in", async () => {
const p = await person("carol");
expect((await badges(undefined, [1])).status).toBe(401);
expect((await badges(p, Array.from({ length: 201 }, (_, i) => i + 1))).status).toBe(400);
expect((await badges(p, Array.from({ length: 200 }, (_, i) => i + 1))).status).toBe(200);
for (const bad of [[0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], ["x"], null, "x", { id: 1 }])
expect(JSON.stringify(bad) + " " + (await badges(p, bad)).status).toBe(JSON.stringify(bad) + " 400");
expect((await call(p, "POST", "/api/v1/badges", {}, true)).status).toBe(400);
});
+239
View File
@@ -0,0 +1,239 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
// proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { eq, 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 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<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; id: 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<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") ?? "",
id: "",
};
expect(p.token).not.toBe("");
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
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, picture: null });
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 registered product with one revision (retracted: that revision is). */
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
}
/** A proxy's live connection, signed in to the client as imvuId. */
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 });
await Bun.sleep(30);
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
test("only the room's owner decorates it; contents are checked", async () => {
const owner = await person("dora", { id: 501 }, { id: 502 });
const other = await person("dave", { id: 511 });
const unlinked = await person("dina");
await product("aaaaaaaaaa", owner.id, "general");
const room = "502-3";
const item = [7, "aaaaaaaaaa", "0 0 0"];
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
// Validation.
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
expect((await put([item], bad)).status).toBe(400);
expect((await put([item], "12345-2100000005")).json.error).toBe("hosted_room");
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
expect((await put([[7, 12345, ""]])).status).toBe(400);
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
expect((await put([item, item])).status).toBe(400);
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
expect(unknown.status).toBe(400);
expect(unknown.json.error).toBe("unknown_product");
// The owner's other account is fine too; up to 300 items.
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
expect(ok.status).toBe(200);
expect(ok.json.contents).toHaveLength(300);
const set = await put([item]);
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
// Not another person's room of the same number.
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
});
test("viewers see the products they may, with a hash of what they see", async () => {
const owner = await person("eli", { id: 601, ageVerified: true });
const kid = await person("emma", { id: 611 });
const grown = await person("evan", { id: 621, ageVerified: true });
await product("cccccccccc", owner.id, "general");
await product("dddddddddd", owner.id, "adult");
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
const room = "601-1";
const g = [1, "cccccccccc", "a"];
const a = [2, "dddddddddd", "b"];
const r = [3, "eeeeeeeeee", "c"];
// Unknown rooms have nothing.
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations/12345-2100000001")).status).toBe(400);
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
expect(put.status).toBe(200);
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
// A product kept but hidden comes back once it has a usable revision again.
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
expect((await view(kid)).contents).toEqual([g, r]);
// Changes change the hash.
const before = (await view(kid)).hash;
const moved = [1, "cccccccccc", "moved"];
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
const after = await view(kid);
expect(after.contents).toEqual([moved, r]);
expect(after.hash).not.toBe(before);
// Several rooms at once, in the order asked.
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
expect(many.json.rooms).toEqual([
{ instance: "601-9", contents: [], hash: hash([]) },
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
]);
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
// An empty list removes them.
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
});
test("proxies in the room are told when its decorations change", async () => {
const owner = await person("fay", { id: 701 });
const guest = await person("finn", { id: 711 });
const away = await person("fred", { id: 721 });
await product("ffffffffff", owner.id, "general");
const room = "701-2";
const host = await proxy(owner, 701); // the owner's client, in the room
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
const inRoom = await proxy(guest, 711);
const elsewhere = await proxy(away, 721);
host.send({ type: "join", chat: "1400000001", room });
inRoom.send({ type: "join", chat: "1400000001", room });
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
elsewhere.send({ type: "join", chat: "1400000003" });
await Bun.sleep(50);
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
await Bun.sleep(50);
expect(editor.decor()).toHaveLength(0);
expect(elsewhere.decor()).toHaveLength(0);
// Not after leaving.
inRoom.send({ type: "leave", chat: "1400000001" });
await Bun.sleep(30);
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await waitFor(() => host.decor().length === 2);
await Bun.sleep(50);
expect(inRoom.decor()).toHaveLength(1);
// A refused change tells no one.
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await Bun.sleep(50);
expect(host.decor()).toHaveLength(2);
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
});
+656
View File
@@ -0,0 +1,656 @@
// 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 { eq, 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 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<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 };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
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("");
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, picture: null });
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, ask };
}
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<string, unknown> = {}) => ({
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: `302-${2_100_000_000 + 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, name: "deenAv" }], sequence: 0 });
const ej = await e.join(n);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
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);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
// 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();
});
// ---- Phase 2 ----
test("moderators boot and ban; owners lift bans", async () => {
const own = await person("ownr", { id: 601 });
const mod = await person("modr", { id: 602 });
const usr = await person("usrr", { id: 603 });
const md2 = await person("mod2", { id: 604 });
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
const many = Array.from({ length: 21 }, (_, i) => i + 1);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
"no_such_imvu_account",
);
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
expect(set.status).toBe(200);
expect(set.json.room.moderators).toEqual([602, 604, 605]);
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
const o = await proxy(own, 601);
const m = await proxy(mod, 602);
const u = await proxy(usr, 603);
const m2 = await proxy(md2, 604);
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
// Who may boot whom.
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
// A boot: out, but may come back.
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
expect((await u.join(n)).type).toBe("room.joined");
// A ban: out, and refused.
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
// Bans: listed for the owner and moderators; lifted by the owner only.
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
expect((await u.join(n)).type).toBe("room.joined");
// A ban for a while; the owner may boot a moderator.
await boot(o, 603, { ban: true, minutes: 60 });
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
expect((await boot(o, 604)).ok).toBe(true);
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
// Logged.
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
const actions = log.map((e) => e.action);
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
expect(actions).toContain("hosted_room.unban");
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
for (const p of [o, m, u, m2]) p.ws.close();
});
test("private rooms and invitations", async () => {
const pam = await person("pamm", { id: 701 });
const quin = await person("quin", { id: 702 });
const rex = await person("rexx", { id: 703 }, { id: 705 });
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
// Listed only for the owner and moderators.
const listed = async (p?: Person, q = "") =>
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
expect(await listed()).toBe(false);
expect(await listed(rex)).toBe(false);
expect(await listed(pam)).toBe(true);
expect(await listed(quin)).toBe(true);
expect(await listed(pam, "?mine=1")).toBe(true);
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
const p = await proxy(pam, 701);
const q = await proxy(quin, 702);
const r = await proxy(rex, 703);
expect((await r.join(n)).reason).toBe("private");
expect((await q.join(n)).type).toBe("room.joined");
const invite = (from: typeof p, room: number, imvu_id: number) =>
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
const joinWith = (x: typeof p, invite: string) =>
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
// Invited: the invitee's sessions are told; it answers; the inviter hears.
const sent = await invite(p, n, 703);
expect(sent.ok).toBe(true);
const id: string = sent.invite;
await waitFor(() => r.last("room.invited", n) !== undefined);
expect(r.last("room.invited", n)).toEqual({
type: "room.invited",
room: n,
invite: id,
from: 701,
room_name: "Hideout",
owner_name: "pammAv",
});
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
await waitFor(() => p.last("room.invite_answer") !== undefined);
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
r.send({ type: "room.invite_reply", invite: id, accept: false });
await waitFor(() => r.got.some((x) => x.message === "already answered"));
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
expect((await joinWith(r, id)).type).toBe("room.joined");
r.ws.close();
const r2 = await proxy(rex, 703);
expect((await r2.join(n)).type).toBe("room.joined");
r2.send({ type: "room.leave", room: n });
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
expect((await joinWith(r2, id)).reason).toBe("private");
// Declined.
const second = (await invite(q, n, 703)).invite;
await waitFor(() => r2.got.some((x) => x.invite === second));
r2.send({ type: "room.invite_reply", invite: second, accept: false });
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
expect((await joinWith(r2, second)).reason).toBe("private");
// Nobody online as that account; banned accounts aren't invited.
const off = await invite(p, n, 705);
expect(off.ok).toBe(true);
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
type: "room.invite_answer",
invite: off.invite,
to: 705,
accept: false,
offline: true,
});
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
// Public rooms: anyone in it invites.
expect((await r2.join(pub)).type).toBe("room.joined");
expect((await invite(r2, pub, 701)).ok).toBe(true);
await waitFor(() => p.last("room.invited", pub) !== undefined);
for (const x of [p, q, r2]) x.ws.close();
});
const png = (bytes = 64) => {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return b;
};
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
const form = new FormData();
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
method: "PUT",
headers: { Origin: webURL, Cookie: p.cookie },
body: form,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
test("room images", async () => {
const ivy = await person("ivyy", { id: 801 });
const jon = await person("jonn", { id: 802 });
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
expect((await upload(jon, n, png())).status).toBe(403);
const gif = new TextEncoder().encode("GIF89a................................");
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect((await upload(ivy, n, svg)).status).toBe(400);
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
const up = await upload(ivy, n, png());
expect(up.status).toBe(200);
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(got.status).toBe(200);
expect(got.headers.get("content-type")).toBe("image/png");
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
const etag = got.headers.get("etag")!;
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
expect((await upload(ivy, n, jpeg)).status).toBe(200);
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(again.headers.get("content-type")).toBe("image/jpeg");
expect(again.headers.get("etag")).not.toBe(etag);
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
});
test("favorites, ratings and recent rooms", async () => {
const kim = await person("kimm", { id: 901 });
const lou = await person("louu", { id: 902 });
const mia = await person("miaa", { id: 903 });
const ns: number[] = [];
for (const name of ["One", "Two", "Three"])
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
const [a, b, c] = ns as [number, number, number];
// Favorites.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
expect(await favs(lou)).toEqual([c, a]);
expect(await favs(mia)).toEqual([]);
const flag = async (p: Person | undefined, n: number) =>
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
expect(await flag(lou, a)).toBe(true);
expect(await flag(mia, a)).toBe(false);
expect(await flag(undefined, a)).toBe(false);
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect(await favs(lou)).toEqual([c]);
// Ratings.
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
expect(one.json.myRating).toBe(4);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
// Recent rooms: joined, latest first, each once.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
const l = await proxy(lou, 902);
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
for (const n of [a, b, c]) {
await l.join(n);
await Bun.sleep(20);
}
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
expect(await recent()).toEqual([c, b, a]);
l.send({ type: "room.leave", room: a });
await Bun.sleep(30);
await l.join(a);
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
expect(await recent()).toEqual([a, c, b]);
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
expect(visit.map((v) => v.imvuId)).toEqual([902]);
l.ws.close();
});
test("watching rooms' occupancy", async () => {
const ned = await person("nedd", { id: 1001 });
const ola = await person("olaa", { id: 1002 });
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
const w = await proxy(ola, 1002);
const n = await proxy(ned, 1001);
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
// The private room's owner may watch it.
n.send({ type: "room.watch", rooms: [hidden] });
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
await n.join(a);
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
n.send({ type: "room.seat", room: a, seat: 2 });
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
n.send({ type: "room.leave", room: a });
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
await n.join(hidden);
await Bun.sleep(50);
expect(w.last("room.occupancy", hidden)).toBeUndefined();
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
// Replaced; closing tells watchers it's empty.
w.send({ type: "room.watch", rooms: [b] });
await Bun.sleep(50);
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
await n.join(a);
await n.join(b);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
w.ws.close();
n.ws.close();
});
+13 -6
View File
@@ -130,14 +130,14 @@ describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => { test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser(); const carol = new Browser();
await carol.signup("carol"); await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false }); accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null });
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" }); const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/); expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check"); const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409); expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing"); expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false }); const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null });
expect(linked.status).toBe(200); expect(linked.status).toBe(200);
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true }); expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe( expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
@@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => {
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser(); const dave = new Browser();
await dave.signup("dave"); await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false }); const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null });
expect(moved.json.moved).toBe(true); expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId); expect(after.userId).not.toBe(before.userId);
@@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => {
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id; const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200); expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false }); const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null });
expect(over.status).toBe(403); expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap"); expect(over.json.error).toBe("link_cap");
}); });
@@ -170,7 +170,7 @@ describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => { test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser(); const erin = new Browser();
await erin.signup("erin"); await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false }); await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null });
// The proxy asks for a code; until it's approved, the token request waits. // The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, { const codeRes = await fetch(`${base}/api/auth/device/code`, {
@@ -213,6 +213,13 @@ describe("T5DE's proxy", () => {
await new Promise((r) => (ws.onopen = r)); await new Promise((r) => (ws.onopen = r));
ws.send(JSON.stringify({ type: "session", imvu_id: 42 })); ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
await waitFor(() => messages.some((m) => m.message === "not_linked")); await waitFor(() => messages.some((m) => m.message === "not_linked"));
// An account linked while the client runs counts at once.
ws.send(JSON.stringify({ type: "session", imvu_id: 78 }));
await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2);
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null });
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78));
await erin.call("DELETE", "/api/v1/account/links/78");
await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78));
ws.send(JSON.stringify({ type: "session", imvu_id: 77 })); ws.send(JSON.stringify({ type: "session", imvu_id: 77 }));
ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] })); ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] }));
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1)); await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1));
@@ -227,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.
+315
View File
@@ -0,0 +1,315 @@
// The product registry: catalogs, uploads registered by catalogs, product info, revisions, libraries, tokens.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const pm = url.pathname.match(/^\/product\/product-(\d+)$/);
if (pm) {
const pid = Number(pm[1]);
const data = imvuProducts.get(pid);
if (data === "down") throw new Error("connection refused");
if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 });
return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } });
}
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<void>;
let base: string;
let stop: () => void;
let hub: ReturnType<typeof createApp>;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
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 };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
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("; ") };
if (imvu) {
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false, picture: null });
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;
}
// Set by the first test for the next: the admin and an approved catalog.
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
test("catalogs register uploads; products have revisions, info and libraries", async () => {
const admin = await person("admin1");
const maker = await person("maker", { id: 501, ageVerified: true });
const kid = await person("kid", { id: 502, ageVerified: false });
// A catalog registers; it can't do anything until approved.
const reg = await call(maker, "POST", "/api/v1/catalogs", {
name: "Maker's Shelf",
url: "http://localhost:4102/",
contact: "[email protected]",
visibility: "public",
membership: "open",
});
expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
shared = { admin, catalogID, auth };
expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
expect((await call(admin, "POST", `/api/v1/console/catalogs/${catalogID}/approve`)).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs.map((c: any) => c.name)).toEqual(["Maker's Shelf"]);
// The maker gets an upload token; the catalog registers what was uploaded with it.
const up = await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID });
expect(up.json.url).toBe("http://localhost:4102/api/v1/uploads");
const files = [file("tex.png", 2), file("index.xml", 1)];
const revision = (token: string, extra: object = {}) =>
call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files, rating: "general", parent: 500, name: "Blue Coat", notes: "", ...extra }, auth);
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
expect(page.revisions[0].files.map((f: any) => f.name)).toEqual(["index.xml", "tex.png"]);
expect(page.revisions[0].catalogs).toEqual(["Maker's Shelf"]);
// Descriptions are cleaned by the allowlist.
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"><img src="relative.png"><img src="https://img.test/a.png"></p>`,
});
expect(edited.status).toBe(200);
expect(edited.json.product.description).toBe(
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a><img src="https://img.test/a.png" /></p>`,
);
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
// A new revision needs notes and different files.
const up2 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).json.token;
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => [l.product.id, l.product.creator])).toEqual([[id, "maker"]]);
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(1);
// Retracting revision 1 moves those staying on it to the nearest one that isn't.
await call(maker, "POST", `/api/v1/products/${id}/revisions/1/retract`);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(2);
// The revision token is the hub's signature on the files, checkable with its public keys.
const keys = createLocalJWKSet((await call(undefined, "GET", "/.well-known/t5de-keys.json")).json);
const { payload } = await jwtVerify(files2.token, keys, { issuer: apiURL });
expect(payload).toMatchObject({ typ: "t5de-revision", product: id, revision: 2, rating: "general", parent: 500 });
// Access tokens say whether the person is adult.
const access = async (p: Person) => (await jwtVerify((await call(p, "POST", "/api/v1/access")).json.token, keys)).payload;
expect(await access(maker)).toMatchObject({ typ: "t5de-access", adult: true });
expect(await access(kid)).toMatchObject({ typ: "t5de-access", adult: false });
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
// Lowering a rating waits for review.
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed");
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
});
test("IMVU product info: parsing and image addresses", () => {
expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png");
expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png");
for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://[email protected]/a.png", "javascript:alert(1)", 5, null])
expect(imvuImage(bad)).toBeNull();
const answer = {
denormalized: {
"https://api.imvu.com/product/product-9": {
data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] },
},
},
};
expect(parseProduct(answer, 9)).toEqual({
image: "https://x.imvu.com/i.png",
categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }],
gender: "female",
});
expect(parseProduct(answer, 10)).toBeNull();
});
test("derived products carry their IMVU parent's image and categories", async () => {
const { catalogID, auth } = shared;
const dresser = await person("dresser", { id: 601, ageVerified: false });
const coat = {
product_id: 7001,
product_name: "Coat",
rating: "GA",
product_image: "https://userimages-akm.imvu.com/productdata/coat.png",
gender: "female",
category_path: [
{ id: 106, name: "Clothing" },
{ id: 40, name: "Clothing for Female" },
{ id: 128, name: "Tops" },
{ id: 716, name: "Coats & Jackets" },
],
};
imvuProducts.set(7001, coat);
imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] });
imvuProducts.set(7003, "down");
const upload = async (name: string, parent: number) => {
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
const hatID = await upload("Dresser Hat", 7002);
const downID = await upload("Dresser Down", 7003);
const plainID = await upload("Dresser Plain", 0);
const missingID = await upload("Dresser Missing", 7999);
const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product;
// Looked up in the background after registering.
for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20);
expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20);
expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] });
expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] });
expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] });
// Lists carry the same fields; category filters by any ID in the path.
const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products;
const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort();
expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
expect(await names("&category=128")).toEqual(["Dresser Coat"]);
expect(await names("&category=106")).toEqual(["Dresser Coat"]);
expect(await names("&category=75")).toEqual(["Dresser Hat"]);
expect(await names("&category=1")).toEqual([]);
expect(await names("&limit=2")).toHaveLength(2);
expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400);
// IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty.
imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] });
await hub.products.fillParentInfo(50);
expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] });
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0);
// The library shows them too.
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
});
test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => {
const user = (thumbnail_url: unknown) =>
parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } });
expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png");
expect(user("https://evil.example/p.png").picture).toBeNull();
expect(user(undefined).picture).toBeNull();
});
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("deleter", { id: 511, ageVerified: true });
const owner = await person("keeper", { id: 512, ageVerified: true });
const late = await person("latecomer", { id: 513, ageVerified: true });
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
const id = await make("Gone Hat");
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
// the files stay for those who have it.
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
expect(await listed()).toEqual([]);
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
// Moderation removes one: no page, no files, out of libraries.
const bad = await make("Gone Shoes");
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});
+192
View File
@@ -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, picture: null });
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();
});
+1 -1
View File
@@ -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));
+6 -1
View File
@@ -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">
+5 -1
View File
@@ -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&apos;m online in T5DE Show when I&apos;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>
); );
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { api, ApiError, unwrap } from "@/lib/api";
import { field } from "@/lib/actions";
export type RegisterState = { error?: string; registered?: { id: string; name: string; key: string } } | null;
/** Registers a catalog; its key is in the answer, the only time it's shown. */
export async function registerCatalog(_: RegisterState, form: FormData): Promise<RegisterState> {
try {
const { catalog, key } = unwrap(
await api.catalogs.post({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: field(form, "visibility"),
membership: field(form, "membership"),
}),
);
revalidatePath("/catalogs");
return { registered: { id: catalog.id, name: catalog.name, key } };
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import { CatalogStatus, membership } from "@/components/catalogs";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, getMe, unwrap, type Catalog } from "@/lib/api";
import { RegisterForm } from "./register-form";
export const metadata: Metadata = { title: "Catalogs" };
export default async function Catalogs() {
const [me, { catalogs }] = await Promise.all([getMe(), api.catalogs.get().then(unwrap)]);
const mine = catalogs.filter((c) => c.mine);
const directory = catalogs.filter((c) => !c.mine && c.status === "approved");
return (
<div className="space-y-6">
<PageHeader title="Catalogs">Servers that host community products.</PageHeader>
{mine.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Yours</CardTitle>
</CardHeader>
<CardContent>
<CatalogTable catalogs={mine} showStatus />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
</CardHeader>
<CardContent>
{directory.length === 0 ? (
<p className="text-sm text-muted-foreground">No catalogs yet.</p>
) : (
<CatalogTable catalogs={directory} />
)}
</CardContent>
</Card>
{me && (
<Card>
<CardHeader>
<CardTitle>Register a catalog</CardTitle>
<CardDescription>Run t5de-catalog, then register its address here.</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm />
</CardContent>
</Card>
)}
</div>
);
}
function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Membership</TableHead>
{showStatus && <TableHead>Visibility</TableHead>}
{showStatus && <TableHead>Status</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
{showStatus && (
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useActionState } from "react";
import { CopyButton } from "@/components/copy-button";
import { ErrorText, SubmitButton } from "@/components/forms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { registerCatalog } from "./actions";
export function RegisterForm() {
const [state, action] = useActionState(registerCatalog, null);
if (state?.registered) {
const { id, name, key } = state.registered;
return (
<Alert>
<AlertTitle>{name} is registered</AlertTitle>
<AlertDescription className="space-y-3">
<p>Put these in the catalog&apos;s settings. The key is shown only once. A moderator approves it next.</p>
<KeyLine label="CATALOG_ID" value={id} />
<KeyLine label="CATALOG_KEY" value={key} />
</AlertDescription>
</Alert>
);
}
return (
<form action={action}>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" required maxLength={300} placeholder="https://" />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" required maxLength={200} placeholder="Email or URL" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="visibility">Visibility</FieldLabel>
<NativeSelect id="visibility" name="visibility" defaultValue="public" className="w-full">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="membership">Membership</FieldLabel>
<NativeSelect id="membership" name="membership" defaultValue="open" className="w-full">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="approval">By approval</NativeSelectOption>
<NativeSelectOption value="invite">Invite only</NativeSelectOption>
</NativeSelect>
</Field>
</div>
</div>
<div>
<SubmitButton pendingLabel="Registering…">Register</SubmitButton>
</div>
{state?.error && <ErrorText>{state.error}</ErrorText>}
</FieldGroup>
</form>
);
}
function KeyLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-28 font-mono text-xs text-muted-foreground">{label}</span>
<code className="min-w-0 rounded-md border bg-muted px-2 py-1 font-mono text-xs break-all text-foreground">
{value}
</code>
<CopyButton text={value} />
</div>
);
}
+10
View File
@@ -42,3 +42,13 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
return `Role set to ${person.role}.`; return `Role set to ${person.role}.`;
}); });
} }
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
return attempt(async () => {
unwrap(await api.console.catalogs({ id })({ decision }).post());
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
});
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { CatalogStatus, membership } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { decideCatalog } from "../actions";
import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "Catalogs" };
export default async function ConsoleCatalogs() {
await requireModerator("/console/catalogs");
const { catalogs } = unwrap(await api.console.catalogs.get());
return (
<>
<PageHeader title="Catalogs" />
<ConsoleNav />
<Card>
<CardContent>
{catalogs.length === 0 ? (
<p className="text-sm text-muted-foreground">None registered.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Takedown contact</TableHead>
<TableHead>Access</TableHead>
<TableHead>Registered</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-mono text-xs">{c.url}</TableCell>
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
<TableCell className="text-muted-foreground">
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={c.createdAt} dateOnly />
</TableCell>
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
<TableCell className="text-right">
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<div className="flex justify-end gap-2">
{c.status !== "approved" && (
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
)}
{c.status !== "rejected" && (
<SubmitButton
size="sm"
variant="outline"
name="decision"
value="reject"
pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`}
>
Reject
</SubmitButton>
)}
</div>
</ActionForm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+1
View File
@@ -17,6 +17,7 @@ export function ConsoleNav() {
Overview Overview
</NavLink> </NavLink>
<NavLink href="/console/people">People</NavLink> <NavLink href="/console/people">People</NavLink>
<NavLink href="/console/catalogs">Catalogs</NavLink>
</nav> </nav>
); );
} }
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Where creating a product used to be.
export default function Create() {
redirect("/products/new");
}
+34 -1
View File
@@ -128,4 +128,37 @@
html { html {
@apply font-sans; @apply font-sans;
} }
} }
/* Product descriptions: creators' HTML (cleaned by the API), laid out plainly. */
@layer components {
.product-description {
@apply space-y-3 text-sm leading-relaxed break-words;
}
.product-description :where(h1, h2, h3, h4, h5, h6) {
@apply font-semibold;
}
.product-description :where(h1) {
@apply text-xl;
}
.product-description :where(h2) {
@apply text-lg;
}
.product-description :where(a) {
@apply underline underline-offset-4;
}
.product-description :where(ul) {
@apply list-disc pl-5;
}
.product-description :where(ol) {
@apply list-decimal pl-5;
}
.product-description :where(img) {
@apply inline-block h-auto max-w-full;
}
.product-description :where(td, th) {
@apply border px-2 py-1;
}
.product-description :where(hr) {
@apply my-4;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
rect { fill: #171717; } text { fill: #fafafa; }
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
</style>
<rect width="32" height="32" rx="7"/>
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
</svg>

Before

Width:  |  Height:  |  Size: 397 B

+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { StagingBanner } from "@/components/staging-banner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "./globals.css"; import "./globals.css";
@@ -16,6 +17,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return ( return (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}> <html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col"> <body className="flex min-h-dvh flex-col">
<StagingBanner />
<Header /> <Header />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
<footer className="border-t"> <footer className="border-t">
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { Empty, ProductCard } from "@/components/products";
import { PageHeader } from "@/components/status";
import { api, getMe, unwrap } from "@/lib/api";
import { libraryAction } from "../products/actions";
export const metadata: Metadata = { title: "Library" };
export default async function Library() {
const me = await getMe();
if (!me) redirect("/signin?next=/library");
const { library } = await api.library.get().then(unwrap);
return (
<>
<PageHeader title="Your library">Products your T5DE uses, on all your IMVU accounts.</PageHeader>
{library.length === 0 ? (
<Empty>
Nothing yet.{" "}
<Link href="/products" className="text-foreground underline underline-offset-4">
Browse products
</Link>
.
</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{library.map(({ product, stayOn }) => (
<li key={product.id}>
<ProductCard product={product}>
{stayOn !== null && <p className="text-xs">Staying on revision {stayOn}</p>}
<ActionForm action={libraryAction} fields={{ id: product.id }} className="pt-1">
<SubmitButton size="sm" variant="outline" name="intent" value="remove" pendingLabel="Removing…">
Remove
</SubmitButton>
</ActionForm>
</ProductCard>
</li>
))}
</ul>
)}
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
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, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, 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 { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api";
import { saveProduct } from "../../actions";
import { loadProduct } from "../../load";
export const metadata: Metadata = { title: "Edit product" };
export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const me = await getMe();
if (!me) redirect(`/signin?next=/products/${id}/edit`);
const { product, mine } = await loadProduct(id);
if (!mine) notFound();
return (
<div className="space-y-6">
<PageHeader title={`Edit ${product.name}`}>
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
Back to the product
</Link>
</PageHeader>
<Card>
<CardHeader>
<CardTitle>Info</CardTitle>
</CardHeader>
<CardContent>
<ActionForm action={saveProduct} fields={{ id }}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={product.name} required maxLength={64} className="max-w-md" />
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea
id="description"
name="description"
defaultValue={product.description}
maxLength={20000}
rows={8}
className="font-mono text-xs md:text-xs"
/>
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{product.rating === "adult" ? (
<p className="text-sm">Adult</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
<UploadCard title="Upload a new revision" product={id} />
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, ProductImage, RatingTag } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { getMe, isModerator, type Revision } from "@/lib/api";
import { deleteProduct, libraryAction, removeProduct, retract } from "../actions";
import { loadProduct } from "../load";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { product } = await loadProduct((await params).id);
return { title: product.name };
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
return (
<div className="space-y-6">
<PageHeader title={product.name}>
<span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} />
{product.deleted && <Tag tone="danger">deleted</Tag>}
</span>
</PageHeader>
{product.deleted && (
<p className="text-sm text-muted-foreground">
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
</p>
)}
{(product.image || product.categories.length > 0) && (
<div className="flex flex-wrap items-center gap-4">
{product.image && (
<div className="w-40 overflow-hidden rounded-xl ring-1 ring-foreground/10">
<ProductImage src={product.image} />
</div>
)}
{product.categories.length > 0 && (
<p className="text-sm text-muted-foreground">{product.categories.map((c) => c.name).join(" › ")}</p>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{product.deleted && !inLibrary ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
<>
<Tag tone="strong">In your library</Tag>
<SubmitButton variant="outline" name="intent" value="remove" pendingLabel="Removing…">
Remove
</SubmitButton>
</>
) : (
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
Get
</SubmitButton>
)}
</div>
</ActionForm>
) : (
<Link href={`/signin?next=/products/${id}`} className={buttonVariants()}>
Sign in to get it
</Link>
)}
{mine && !product.deleted && (
<>
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
<ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton
variant="outline"
pendingLabel="Deleting…"
confirm="Delete this product? Nobody new can get it. People who have it keep it."
>
Delete
</SubmitButton>
</ActionForm>
</>
)}
</div>
{product.description && (
<Card>
<CardContent>
{/* Cleaned by the API's allowlist when saved; the page's CSP blocks scripts besides. */}
<div className="product-description" dangerouslySetInnerHTML={{ __html: product.description }} />
</CardContent>
</Card>
)}
{isModerator(me) && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
</CardHeader>
<CardContent>
<ActionForm action={removeProduct} fields={{ id }} className="flex flex-wrap items-center gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton
variant="destructive"
pendingLabel="Removing…"
confirm="Remove this product everywhere? Its page, files and library entries go."
>
Remove
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Revisions</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y">
{revisions.map((r) => (
<RevisionRow
key={r.number}
id={id}
revision={r}
latest={r.number === product.latest}
inLibrary={inLibrary}
stayOn={stayOn}
using={using === r.number}
mine={mine}
/>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
/** The revision T5DE uses (as the API's revisionFor): want, else the nearest earlier one not retracted, else later. */
function inUse(revisions: Revision[], want: number): number | undefined {
const usable = revisions.filter((r) => !r.retracted); // newest first
return (usable.find((r) => r.number === want) ?? usable.find((r) => r.number < want) ?? usable.at(-1))?.number;
}
function RevisionRow({
id,
revision: r,
latest,
inLibrary,
stayOn,
using,
mine,
}: {
id: string;
revision: Revision;
latest: boolean;
inLibrary: boolean;
stayOn: number | null;
using: boolean;
mine: boolean;
}) {
const facts = [
formatSize(r.size),
`${r.files.length} ${r.files.length === 1 ? "file" : "files"}`,
r.catalogs.length > 0 ? r.catalogs.join(", ") : "no catalog",
];
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">Revision {r.number}</span>
<span className="text-sm text-muted-foreground">
<DateTime iso={r.createdAt} dateOnly />
</span>
{latest && <Tag>latest</Tag>}
<RatingTag rating={r.rating} />
{r.retracted && <Tag tone="danger">retracted</Tag>}
{using && <Tag tone="strong">{stayOn === r.number ? "staying on this" : "in use"}</Tag>}
</div>
{r.notes && <p className="text-sm whitespace-pre-line">{r.notes}</p>}
<p className="text-xs text-muted-foreground">{facts.join(" · ")}</p>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Files</summary>
<ul className="mt-2 space-y-1 font-mono text-xs">
{r.files.map((f) => (
<li key={f.name} className="flex gap-3">
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
</li>
))}
</ul>
</details>
{((inLibrary && !r.retracted) || (mine && !r.retracted)) && (
<div className="flex flex-wrap gap-2">
{inLibrary && !r.retracted && (
<ActionForm action={libraryAction} fields={{ id, revision: r.number }}>
{stayOn === r.number ? (
<SubmitButton size="sm" variant="outline" name="intent" value="follow" pendingLabel="Saving…">
Follow latest
</SubmitButton>
) : (
<SubmitButton size="sm" variant="outline" name="intent" value="stay" pendingLabel="Saving…">
Stay on this revision
</SubmitButton>
)}
</ActionForm>
)}
{mine && !r.retracted && (
<ActionForm action={retract} fields={{ id, revision: r.number }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Retracting…"
confirm={`Retract revision ${r.number}? T5DE stops using it.`}
>
Retract
</SubmitButton>
</ActionForm>
)}
</div>
)}
</li>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = (id: string) => {
revalidatePath(`/products/${id}`);
revalidatePath("/library");
};
/** The person's library and a product: get it, remove it, stay on a revision or follow the latest. */
export async function libraryAction(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const lib = api.library({ id });
switch (field(form, "intent")) {
case "get":
unwrap(await lib.put());
break;
case "remove":
unwrap(await lib.delete());
break;
case "stay":
unwrap(await lib.patch({ stayOn: Number(field(form, "revision")) }));
break;
case "follow":
unwrap(await lib.patch({ stayOn: null }));
break;
}
done(id);
});
}
/** Its creator deletes the product. */
export async function deleteProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).delete());
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderation removes the product. */
export async function removeProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).remove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect("/products");
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).revisions({ n: Number(field(form, "revision")) }).retract.post());
done(id);
});
}
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const rating = field(form, "rating");
const state = await attempt(async () => {
unwrap(
await api.products({ id }).patch({
name: field(form, "name"),
description: typeof form.get("description") === "string" ? String(form.get("description")) : "",
...(rating === "adult" ? { rating: "adult" as const } : {}),
}),
);
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload(
catalog: string,
product?: string,
): Promise<{ token: string; url: string } | { error: string }> {
try {
return unwrap(await api.products.upload.post({ catalog, ...(product ? { product } : {}) }));
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { notFound } from "next/navigation";
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
/** The product, or not found (also when it's adult and the visitor can't see adult products, or it was removed). */
export async function loadProduct(id: string): Promise<ProductPage> {
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
try {
return unwrap(await api.products({ id }).get());
} catch (e) {
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
throw e;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/status";
import { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api";
export const metadata: Metadata = { title: "New product" };
export default async function NewProduct() {
const me = await getMe();
if (!me) redirect("/signin?next=/products/new");
return (
<div className="space-y-6">
<PageHeader title="New product">
Upload it to a catalog. You can edit its info after.{" "}
<Link href="/products" className="underline-offset-4 hover:underline">
Back to products
</Link>
</PageHeader>
<UploadCard title="Upload" />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { Empty, ProductCard } from "@/components/products";
import Link from "next/link";
import { PageHeader } from "@/components/status";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api, getMe, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Products" };
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]);
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">Community products for T5DE.</PageHeader>
{me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
</Link>
)}
</div>
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
<Button type="submit" variant="outline">
Search
</Button>
</form>
{products.length === 0 ? (
<Empty>{q ? "Nothing found." : "No products yet."}</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
)}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
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 { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { closeRoom, removeImage, saveImage, 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 (
<div className="space-y-6">
<PageHeader title={`Edit ${room.name}`}>
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardHeader>
<CardDescription>
Owned by {room.ownerName} · {room.occupancy} here now
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveRoom} fields={{ n: room.n }}>
<FieldGroup>
<RoomFields room={room} canAdult={links.some((l) => l.adult)} moderators />
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Image</CardTitle>
<CardDescription>Shown in room lists.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{room.image && (
// The room's image, from the hub.
<img src={room.image} alt="" className="aspect-[332/281] w-full max-w-xs rounded-md bg-muted object-cover" />
)}
<ActionForm action={saveImage} fields={{ n: room.n }}>
<Field>
<FieldLabel htmlFor="image">{room.image ? "Replace" : "Upload"}</FieldLabel>
<Input id="image" name="image" type="file" accept="image/png,image/jpeg" required className="max-w-md" />
<FieldDescription>PNG or JPEG, up to 512 KB.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Uploading…">Save image</SubmitButton>
</div>
</ActionForm>
{room.image && (
<ActionForm action={removeImage} fields={{ n: room.n }}>
<SubmitButton variant="outline" pendingLabel="Removing…">
Remove image
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Close room</CardTitle>
<CardDescription>Everyone in it is sent out. It can&apos;t be opened again.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={closeRoom} fields={{ n: room.n }}>
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
Close room
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"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));
const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const));
const moderators = (form: FormData) =>
field(form, "moderators")
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
/** Makes a hosted room, then goes back to the list. */
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
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")),
privacy: privacy(form),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** The owner's changes to a room. */
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
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")),
privacy: privacy(form),
moderators: moderators(form),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** The room's image: a PNG or JPEG, up to 512 KB. */
export async function saveImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const image = form.get("image");
if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." };
if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." };
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.put({ image }));
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
return "Image saved.";
});
}
export async function removeImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.delete());
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
});
}
/** Stars a room, or (favorite=0) unstars it. */
export async function setFavorite(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
const room = api["hosted-rooms"]({ n }).favorite;
unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete()));
revalidatePath("/rooms");
});
}
export async function rateRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) }));
revalidatePath("/rooms");
});
}
/** Closes a room: everyone in it is sent out. */
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
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");
}
+61
View File
@@ -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 (
<div className="space-y-6">
<PageHeader title="New room">
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardContent>
{links.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
<Link href="/account" className="text-foreground underline underline-offset-4">
Link an IMVU account
</Link>{" "}
first: it&apos;s shown as the room&apos;s owner.
</p>
) : (
<ActionForm action={createRoom}>
<FieldGroup>
<RoomFields canAdult={links.some((l) => l.adult)} />
<Field>
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
{links.map((l) => (
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
{l.username}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
)}
</CardContent>
</Card>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
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, getMe, unwrap, type HostedRoom } from "@/lib/api";
import { RoomActions } from "./room-actions";
export const metadata: Metadata = { title: "Rooms" };
export default async function Rooms() {
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 (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
{me && (
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
New room
</Link>
)}
</div>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
{hosted.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{hosted.map((r) => (
<li key={r.n}>
<HostedCard room={r} mine={own.has(r.n)} signedIn={!!me} />
</li>
))}
</ul>
)}
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
{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>
)}
</section>
</div>
);
}
function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
return (
<Card className="h-full overflow-hidden py-0">
{r.image && (
// The room's image, from the hub.
<img src={r.image} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
<CardContent className="space-y-3 py-6">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.name}</span>
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
{r.privacy === "private" && <Tag>Private</Tag>}
{r.occupancy > 0 && (
<Tag tone="online" dot>
{r.occupancy} here
</Tag>
)}
{mine && (
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
Edit
</Link>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.ownerName} · {r.occupancy}/{r.maxUsers}
{r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{signedIn && <RoomActions n={r.n} favorite={r.favorite} canRate={!mine} />}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
// A hosted room card's favorite star and rating buttons.
import { Star } from "lucide-react";
import { ActionForm, SubmitButton } from "@/components/forms";
import { cn } from "@/lib/utils";
import { rateRoom, setFavorite } from "./actions";
export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) {
return (
<div className="flex flex-wrap items-center gap-3">
<ActionForm action={setFavorite} fields={{ n, favorite: favorite ? "0" : "1" }} className="space-y-0">
<SubmitButton variant="ghost" size="icon" aria-label={favorite ? "Unstar" : "Star"} title={favorite ? "Unstar" : "Star"}>
<Star className={cn("size-4", favorite && "fill-current text-amber-500")} />
</SubmitButton>
</ActionForm>
{canRate && (
<ActionForm action={rateRoom} fields={{ n }} className="flex items-center gap-0.5 space-y-0">
<span className="mr-1 text-xs text-muted-foreground">Rate</span>
{[1, 2, 3, 4, 5].map((k) => (
<SubmitButton key={k} name="score" value={String(k)} variant="ghost" size="xs" aria-label={`${k} of 5`}>
{k}
</SubmitButton>
))}
</ActionForm>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// 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";
/** The moderators as the edit field shows them: names where known, else IDs. */
const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", ");
export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) {
return (
<>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
</Field>
<div className="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
<Input
id="roomPid"
name="roomPid"
type="number"
inputMode="numeric"
min={1}
required
defaultValue={room?.roomPid}
placeholder="Product ID"
/>
<FieldDescription>An IMVU room product&apos;s ID, from its shop page.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
Adult
</NativeSelectOption>
</NativeSelect>
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
<Input
id="maxUsers"
name="maxUsers"
type="number"
inputMode="numeric"
min={2}
max={30}
required
defaultValue={room?.maxUsers ?? 10}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="privacy">Privacy</FieldLabel>
<NativeSelect id="privacy" name="privacy" defaultValue={room?.privacy ?? "public"} className="w-64">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
<FieldDescription>Private: unlisted; only you, moderators and people invited get in.</FieldDescription>
</Field>
{moderators && room && (
<Field>
<FieldLabel htmlFor="moderators">Moderators</FieldLabel>
<Input id="moderators" name="moderators" defaultValue={moderatorList(room)} placeholder="IMVU names or IDs" />
<FieldDescription>Up to 20, comma-separated. They can boot and ban.</FieldDescription>
</Field>
)}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Tag } from "@/components/status";
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
/** A catalog's review status: approved, pending or rejected. */
export function CatalogStatus({ status }: { status: string }) {
if (status === "approved") return <Tag tone="strong">approved</Tag>;
if (status === "rejected") return <Tag tone="danger">rejected</Tag>;
return <Tag>{status}</Tag>;
}
+62 -15
View File
@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Menu } from "@base-ui/react/menu";
import { ChevronDown } from "lucide-react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -32,21 +33,67 @@ export function NavLink({
); );
} }
export function SignOutButton() { /** The signed-in person's menu: their picture and name, opening their pages and signing out. */
export function ProfileMenu({ username, picture, console }: { username: string; picture: string | null; console: boolean }) {
const router = useRouter(); const router = useRouter();
const [pending, setPending] = useState(false); const [signingOut, setSigningOut] = useState(false);
const item =
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-muted";
return ( return (
<Button <Menu.Root>
variant="outline" <Menu.Trigger
disabled={pending} className="flex items-center gap-2 rounded-full py-1 pr-2.5 pl-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring data-[popup-open]:bg-muted"
onClick={async () => { aria-label="Your account"
setPending(true); >
await authClient.signOut(); <Avatar username={username} picture={picture} />
router.push("/"); <span className="hidden max-w-40 truncate sm:inline">{username}</span>
router.refresh(); <ChevronDown className="size-4 text-muted-foreground" aria-hidden />
}} </Menu.Trigger>
> <Menu.Portal>
{pending ? "Signing out…" : "Sign out"} <Menu.Positioner align="end" sideOffset={6} className="z-50">
</Button> <Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
<Menu.LinkItem className={item} render={<Link href="/account" />}>
Account
</Menu.LinkItem>
<Menu.LinkItem className={item} render={<Link href="/library" />}>
Library
</Menu.LinkItem>
{console && (
<Menu.LinkItem className={item} render={<Link href="/console" />}>
Console
</Menu.LinkItem>
)}
<Menu.Separator className="-mx-1 my-1 h-px bg-border" />
<Menu.Item
className={item}
disabled={signingOut}
onClick={async () => {
setSigningOut(true);
await authClient.signOut();
router.push("/");
router.refresh();
}}
>
{signingOut ? "Signing out…" : "Sign out"}
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
/** A person's picture (their first linked IMVU account's avatar), or their initial. */
function Avatar({ username, picture }: { username: string; picture: string | null }) {
const [failed, setFailed] = useState(false);
if (picture && !failed) {
return (
<img src={picture} alt="" onError={() => setFailed(true)} className="size-7 rounded-full bg-muted object-cover object-top" />
);
}
return (
<span className="grid size-7 place-items-center rounded-full bg-muted text-xs font-semibold uppercase" aria-hidden>
{username.slice(0, 1)}
</span>
); );
} }
+11 -19
View File
@@ -1,17 +1,12 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getMe, isModerator } from "@/lib/api"; import { getMe, isModerator } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { NavLink, SignOutButton } from "./header-client"; import { NavLink, ProfileMenu } from "./header-client";
import logo from "./t5de.png";
export function Logo() { export function Logo({ className }: { className?: string }) {
return ( return <Image src={logo} alt="T5DE" priority className={className ?? "size-9"} />;
<span
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
aria-hidden
>
T5
</span>
);
} }
export async function Header() { export async function Header() {
@@ -19,21 +14,18 @@ export async function Header() {
return ( return (
<header className="border-b"> <header className="border-b">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold"> <Link href="/" aria-label="T5DE Hub home" className="shrink-0">
<Logo /> <Logo />
T5DE Hub
</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 flex-wrap items-center gap-1 text-sm sm:order-none sm:w-auto">
<NavLink href="/online">Online</NavLink> <NavLink href="/online">Online</NavLink>
{me && <NavLink href="/account">Account</NavLink>} <NavLink href="/rooms">Rooms</NavLink>
{isModerator(me) && <NavLink href="/console">Console</NavLink>} <NavLink href="/products">Products</NavLink>
<NavLink href="/catalogs">Catalogs</NavLink>
</nav> </nav>
<div className="ml-auto flex items-center gap-2 text-sm"> <div className="ml-auto flex items-center gap-2 text-sm">
{me ? ( {me ? (
<> <ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
<SignOutButton />
</>
) : ( ) : (
<> <>
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}> <Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
+73
View File
@@ -0,0 +1,73 @@
// Pieces shared by the product pages.
import Link from "next/link";
import { Tag } from "@/components/status";
import { Card, CardContent } from "@/components/ui/card";
import type { ProductSummary } from "@/lib/api";
export function RatingTag({ rating }: { rating: string }) {
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
}
/** 1536 → "1.5 KB". */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
/** The image of the IMVU product a product derives from (IMVU's, from IMVU), if it has one. */
export function ProductImage({ src, className }: { src: string | null | undefined; className?: string }) {
if (!src) return null;
return (
<img
src={src}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
className={`aspect-[5/4] w-full bg-muted object-contain ${className ?? ""}`}
/>
);
}
export function ProductCard({
product,
children,
}: {
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories">;
children?: React.ReactNode;
}) {
const category = product.categories.at(-1)?.name;
return (
<Card className="h-full">
<ProductImage src={product.image} />
<CardContent className="space-y-2">
<div className="flex items-start justify-between gap-2">
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
{product.name}
</Link>
<RatingTag rating={product.rating} />
</div>
<p className="text-xs text-muted-foreground">
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
</p>
{children}
</CardContent>
</Card>
);
}
export function Empty({ children }: { children: React.ReactNode }) {
return (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">{children}</p>
</CardContent>
</Card>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { headers } from "next/headers";
// A slim bar on staging, so testers don't mistake it for T5DE's hub: shown when HUB_ENVIRONMENT is "staging", or
// (unset) when the site is served from a staging address, such as hub.staging.t5de.dev. Read per request, since
// staging and production run the same image.
export async function StagingBanner() {
const env = process.env.HUB_ENVIRONMENT;
const host = (await headers()).get("host") ?? "";
const staging = env ? env === "staging" : /(^|\.)staging\./.test(host);
if (!staging) return null;
return (
<div className="bg-amber-400 py-1 text-center text-xs font-medium text-amber-950">
Staging ·{" "}
<a href="https://hub.t5de.dev" className="underline underline-offset-2">
hub.t5de.dev
</a>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn" import { cn } from "cn"
const buttonVariants = cva( const variants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
@@ -39,16 +39,19 @@ const buttonVariants = cva(
} }
) )
// Merged, so a variant's border replaces the base's transparent one on links styled as buttons too.
const buttonVariants = (props?: Parameters<typeof variants>[0]) => cn(variants(props))
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof variants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={buttonVariants({ variant, size, className })}
{...props} {...props}
/> />
) )
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UploadForm } from "@/components/upload-form";
import { api, unwrap } from "@/lib/api";
/** Uploading to one of the approved catalogs: a new product, or with product, a new revision of it. */
export async function UploadCard({ title, product }: { title: string; product?: string }) {
const { catalogs } = await api.catalogs.get().then(unwrap);
const approved = catalogs.filter((c) => c.status === "approved").map((c) => ({ id: c.id, name: c.name }));
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{approved.length > 0 ? (
<UploadForm catalogs={approved} product={product} />
) : (
<p className="text-sm text-muted-foreground">
No catalogs to upload to yet. See{" "}
<Link href="/catalogs" className="text-foreground underline underline-offset-4">
catalogs
</Link>
.
</p>
)}
</CardContent>
</Card>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
// Uploading a product (or a new revision of one): the hub gives an upload token, then the browser sends the file to
// the catalog itself, so files never pass through the website's server.
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, 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 { startUpload } from "@/app/products/actions";
type Option = { id: string; name: string };
/** Sends form to url with the token, reporting progress; resolves with the catalog's status and JSON answer. */
function send(url: string, token: string, form: FormData, progress: (fraction: number) => void) {
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
xhr.upload.onprogress = (e) => e.lengthComputable && progress(e.loaded / e.total);
xhr.onload = () => {
let body: unknown = null;
try {
body = JSON.parse(xhr.responseText);
} catch {}
resolve({ status: xhr.status, body });
};
xhr.onerror = () => reject(new Error("network"));
xhr.send(form);
});
}
export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?: string }) {
const router = useRouter();
const [error, setError] = useState("");
const [progress, setProgress] = useState<number | null>(null);
async function submit(form: FormData) {
setError("");
setProgress(0);
const start = await startUpload(String(form.get("catalog")), product);
if ("error" in start) {
setError(start.error);
setProgress(null);
return;
}
const body = new FormData();
body.set("file", form.get("file") as File);
body.set("notes", String(form.get("notes") ?? ""));
try {
const answer = await send(start.url, start.token, body, setProgress);
const value = answer.body as { product?: unknown; message?: unknown } | null;
if (answer.status === 201 && typeof value?.product === "string") {
router.push(`/products/${value.product}`);
router.refresh();
return;
}
setError(
typeof value?.message === "string" && value.message
? value.message
: `The catalog refused the upload (${answer.status}).`,
);
} catch {
setError("Couldn't reach the catalog. Try again in a moment.");
}
setProgress(null);
}
const busy = progress !== null;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit(new FormData(e.currentTarget));
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="catalog">Catalog</FieldLabel>
<NativeSelect id="catalog" name="catalog" required className="w-full max-w-sm">
{catalogs.map((c) => (
<NativeSelectOption key={c.id} value={c.id}>
{c.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="file">File</FieldLabel>
<Input id="file" name="file" type="file" accept=".chkn,.zip" required className="max-w-sm" />
<FieldDescription>A Create Mode .chkn, or a zip of the product&apos;s files.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="notes">{product ? "What changed" : "Notes"}</FieldLabel>
<Textarea id="notes" name="notes" required={!!product} maxLength={4000} rows={3} />
</Field>
<div className="flex items-center gap-3">
<Button type="submit" disabled={busy}>
{busy ? "Uploading…" : "Upload"}
</Button>
{busy && (
<div
className="h-1.5 w-40 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
>
<div className="h-full bg-foreground transition-[width]" style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
{error && <ErrorText>{error}</ErrorText>}
</FieldGroup>
</form>
);
}
+7 -1
View File
@@ -40,6 +40,12 @@ export type Challenge = Account["challenges"][number];
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number]; export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>; export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number]; export type AuditEntry = ConsoleData["audit"][number];
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
export type Revision = ProductPage["revisions"][number];
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
/** An error the API answered: message is written to be shown as is. */ /** An error the API answered: message is written to be shown as is. */
export class ApiError extends Error { export class ApiError extends Error {
@@ -75,4 +81,4 @@ export const getMe = cache(async (): Promise<Person | undefined> => {
return data && "person" in data ? data.person : undefined; return data && "person" in data ? data.person : undefined;
}); });
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin"; export const isModerator = (p: Pick<Person, "role"> | undefined) => p?.role === "moderator" || p?.role === "admin";
+41
View File
@@ -0,0 +1,41 @@
// A Content Security Policy for every page: scripts only from this site with the request's nonce (Next.js adds it to
// its own scripts), so HTML that slips into a page, such as a product's description, can't run anything. Uploads go
// from the browser straight to catalogs, so connect-src allows any https address (and local ones, for development).
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const dev = process.env.NODE_ENV === "development";
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
// Descriptions keep their style attributes (the API's allowlist limits them to colors, sizes and alignment).
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https: data: blob:",
"font-src 'self'",
`connect-src 'self' https: http://localhost:* http://127.0.0.1:*${dev ? " ws:" : ""}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
headers.set("Content-Security-Policy", policy);
const response = NextResponse.next({ request: { headers } });
response.headers.set("Content-Security-Policy", policy);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|icon.svg|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
+38 -1
View File
@@ -14,9 +14,12 @@
"@t5de/db": "workspace:*", "@t5de/db": "workspace:*",
"better-auth": "1.7.14", "better-auth": "1.7.14",
"elysia": "1.4.30", "elysia": "1.4.30",
"jose": "^6.2.14",
"sanitize-html": "^2.18.1",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.4", "@types/bun": "^1.4.4",
"@types/sanitize-html": "^2.16.2",
"typescript": "^7.0.2", "typescript": "^7.0.2",
}, },
}, },
@@ -391,6 +394,8 @@
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/sanitize-html": ["@types/[email protected]", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="],
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -497,6 +502,8 @@
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["[email protected]", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"debounce-fn": ["[email protected]", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], "debounce-fn": ["[email protected]", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -517,6 +524,14 @@
"diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
"domelementtype": ["[email protected]", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
"dotenv": ["[email protected]", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dotenv": ["[email protected]", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -535,12 +550,16 @@
"enquirer": ["[email protected]", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "enquirer": ["[email protected]", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
@@ -587,6 +606,8 @@
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -613,6 +634,8 @@
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
"is-regexp": ["[email protected]", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], "is-regexp": ["[email protected]", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
@@ -643,6 +666,8 @@
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="], "kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -723,6 +748,8 @@
"parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
"path-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -735,7 +762,7 @@
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], "pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"postcss": ["[email protected].23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postcss": ["[email protected]2", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="], "postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="],
@@ -769,6 +796,8 @@
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"sanitize-html": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="],
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -883,6 +912,8 @@
"@shadcn/registry/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], "@shadcn/registry/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
"@shadcn/registry/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
@@ -895,6 +926,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/postcss/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"conf/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "conf/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -909,6 +942,8 @@
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@@ -919,6 +954,8 @@
"shadcn/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], "shadcn/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
"shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"tsx/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "tsx/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
+58 -4
View File
@@ -11,7 +11,7 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session tok
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`, is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`. `code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`.
## Accounts (Better Auth) ## Accounts (Better Auth)
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. | | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
| `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | | | `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}` | | `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
## Proxies ## Proxies
@@ -29,7 +29,8 @@ 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`. | | `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. |
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel, decoration notices (`decor`) and hosted rooms (`room.*`, docs/hosted-rooms.md). 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,15 +40,68 @@ 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}` (each off by default; `showBadge`: others see the T5 badge on this account in the client) |
| `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 |
## Hosted rooms
Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session;
the `GET`s also signed out.
| | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. |
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` |
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. |
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" |
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
privacy, moderators, moderatorNames, image, favorite, score, ratings,
createdAt}`; `instanceId` is `<owner IMVU ID>-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
## Decorations
T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance,
`<owner's customer ID>-<n>` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (room number 2100000000 and up) are refused
(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents`
is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the
sha256 (hex) of `contents` as JSON.
| | |
| --- | --- |
| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. |
| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked |
| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. |
## Products, catalogs and libraries
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
category ID) and `limit` (1-200, default 60).
## Age verification
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
person-level providers (phone wallets, document checks) are started here.
| | |
| --- | --- |
| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks |
| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) |
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature |
## Console (moderators; roles: admins) ## Console (moderators; roles: admins)
| | | | | |
+90 -14
View File
@@ -101,16 +101,43 @@ age, not verified) and `tagline`.
offer to link. Linked to another hub account: no community features that session. offer to link. Linked to another hub account: no community features that session.
- **Bans** apply to the person, so all linked accounts. - **Bans** apply to the person, so all linked accounts.
## Age (**Decided**: IMVU's verification) ## Age (**Decided**: free, several providers, vendor-neutral)
- Adult content requires the **IMVU account in use** to be age verified (`is_ageverified`) or to have Access Pass No payment for verification (adult content rules out mainstream merchants of record; see the discussion of
(`is_ap`; **Decided**: it counts), checked by the hub 2026-10-29). Several ways to prove age, tried in order of privacy, behind one interface so any can be swapped:
itself (never reported by the proxy, which anyone can change): at linking and at most daily after, cached.
Revocations show up at the next check. 1. **IMVU** (live): the IMVU account is age verified (`is_ageverified`) or has Access Pass (`is_ap`), read by the hub
- Per IMVU account, not per person, so one verified account doesn't unlock others a minor might use. from IMVU's public API. Scope: that IMVU account. Re-checked about daily; a revocation shows at the next check.
Self-reported `is_adult` doesn't count.
2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet,
asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or
birth date reach it. Scope: the person.
3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person
(the liveness step stops someone using another person's ID).
### How it's built
- **One table of results**, `age_check`: subject (a person, or one of their linked IMVU accounts), provider,
method (e.g. `imvu_age_verified`, `imvu_access_pass`, `wallet_mdoc`, `id_document`), status (`pending`,
`complete`, `failed`, `revoked`), over 18 yes/no, the provider's reference, when it was checked and when it
expires. Never a birth date, document, photo or name: providers that see those keep them.
- **One rule**: an IMVU account counts as adult if its person has a current complete check that says over 18, or
the account itself has one. Everything (website, tokens for catalogs, room channels) asks this one function.
- **Providers** implement a small interface: `start` (begin a check: done at once, a page to send the person to,
or a request for the browser to hand to a wallet), `complete` (finish from the provider's callback or signed
webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is
configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
one new adapter; nothing else changes.
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature.
- Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room - Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room
channels for viewers who aren't verified and opted in), and the viewer's proxy. channels for viewers who aren't adult and opted in), and the viewer's proxy.
- Self-reported `is_adult` doesn't count.
### To add the planned providers
- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning
only "over 18". Confirm its free monthly allowance covers age checks.
- Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP,
mdoc); Apple and the EU wallet through the same browser API where supported.
## Products and catalogs ## Products and catalogs
@@ -207,6 +234,31 @@ number, `productdata/<id>/<revision>/`) but hardly shows them; T5DE shows them o
- **Seeing versus wearing** (**Decided**): anyone can *see* any catalog's products on someone else (subject to their - **Seeing versus wearing** (**Decided**): anyone can *see* any catalog's products on someone else (subject to their
own age settings); getting and wearing them needs membership where the catalog requires it. own age settings); getting and wearing them needs membership where the catalog requires it.
## In the client's shop and inventory (planned, the user's goal)
- **"Buying" is saving**: getting a T5 product (free) adds it to the person's library on the hub. The proxy shows
library products in the client's own inventory, next to IMVU's, so they're worn like any other.
- **Browsing in the shop**: T5 products appear in the client's shop, with a T5 filter beside IMVU's rating filter
and a T5 badge on T5 products (inventory, shop, product cards). Images are drawn and kept for this (the user's
Pictures\T5DE badges, made by its `draw.py`): badges 18x14, 25x21 and 23x19; filter strips for IMVU's two
layouts with T5 added last (`GA|all|AP|T5`, `GA|AP|AP+|all|T5`) and `all|T5` for people without Access Pass,
who get no rating filter today (T5 products aren't only adult ones).
- **Where the T5 badge goes** (every place the client shows AP/AP+/VIP badges, checked in the client's UI files):
- *Products and outfits* (always, a property of the product): shop results and product pages, inventory,
the outfits viewer and outfit cards (an outfit with any T5 product gets it, as AP outfits do), product
recommendation widgets, checkout ("getting" a T5 product).
- *People* (only if they turned on "Show T5DE badge" for that IMVU account, the same choice everywhere): avatar
cards, the friends list (page and sidebar), the right-click menu on an avatar, chat names, the inbox, the
"invited to chat" and Shop Together invite dialogs, the gifting friend picker, home, and Find People and Get
Matched (with a "T5DE users" filter there).
- *Rooms* (always, not optional: visitors need to know a room needs T5DE to be seen as meant; hosted rooms, and
IMVU rooms decorated with T5 products): the chat rooms browser, room cards, the owner's room manager, and the
room's tab.
The client learns who and what is T5 from the proxy, which asks the hub (badge choices, products, rooms).
- Needs: the hub's product registry and catalogs serving files (so products don't have to be local packs), the
library on the hub, the proxy answering the inventory's and shop's calls for T5 products, and a patch to the
shop page for the filter and badges.
## Outfits ## Outfits
1. The user puts on a community item. The client shows it (proxy answers for it); the patch sends IMVU the outfit 1. The user puts on a community item. The client shows it (proxy answers for it); the patch sends IMVU the outfit
@@ -216,13 +268,27 @@ 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.
## Rooms (**Decided**: both) ## Rooms (**Decided**: both)
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements - **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps
travel on the room channel like outfits. them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations").
- **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions, - **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions,
bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of
IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the
@@ -230,10 +296,17 @@ 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)
- **Badge**: other T5DE users see that this account uses T5DE. - **Badge**: other T5DE users see that this account uses T5DE. The proxy asks the hub which of the accounts it's
showing have it (`POST /api/v1/badges`, docs/api.md).
- **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't - **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't
store friend lists, but it sees them when asked (stated in the privacy text). store friend lists, but it sees them when asked (stated in the privacy text).
@@ -336,9 +409,12 @@ 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 (phase 5, docs/hosted-rooms.md): the hub's side is done (rooms made on the website, REST, the
realtime `room.*` protocol, tested with fake proxies); the proxy's side is under way. 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
+197
View File
@@ -0,0 +1,197 @@
# T5DE-hosted rooms (phase 1)
A hosted room lives on the hub (design.md, Rooms): T5DE users find it in the client's room list, join it, sit and
chat, and its owner decorates it. The client treats it as an ordinary IMVU room: T5DE's proxy answers the client's
room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what the client expects). IMVU never
hears of hosted rooms.
Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the
owner's furniture), occupancy in the room list. Phase 2 (below): moderators, boots and bans, private rooms,
invitations, images, favorites, ratings, recent rooms, occupancy pushed to watchers, participant names.
## IDs
IDs IMVU won't use, so the proxy can tell hosted rooms apart:
- A room has a number `n` (1, 2, ...), its **instance ID** is `<owner's IMVU ID>-<2100000000 + n>` and its
**chat ID** is `2100000000 + n`. The client takes a room's owner from the instance ID's first part (so the owner
can decorate it); IMVU's own room numbers are small. (IMVU's chat IDs were about 1,390,000,000 in 2026-10, and XML-RPC integers end at
2,147,483,647; IMVU customer IDs, the first part of its instance IDs, are far below 2,100,000,000.)
- The room shell is an IMVU product (`roomPid`), chosen when the room is made (any room product; the client loads
it like any room).
## REST (bearer or cookie session)
| Route | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | `{name, description, roomPid, rating, maxUsers, imvuId}`: make a room. `imvuId` is one of the person's linked IMVU accounts, shown as the owner. `rating` `general` or `adult` (adult needs an adult person). `maxUsers` 2-30, default 10. Name 1-64 characters, description up to 512. A person may own up to 10 open rooms. |
| `GET /api/v1/hosted-rooms` | (also signed out) `{rooms: [room]}`, open rooms, newest first; adult rooms only for adults. `?mine=1`: the person's own. |
| `GET /api/v1/hosted-rooms/:n` | (also signed out) `{room, scene: {contents, hash}}`; 404 `not_found` (or adult, for others), 410 `closed`. |
| `PATCH /api/v1/hosted-rooms/:n` | owner: `{name?, description?, rating?, maxUsers?, roomPid?}`. |
| `PUT /api/v1/hosted-rooms/:n/scene` | owner: `{contents, roomPid?}`, from the client's `test.setRoomState`. |
| `DELETE /api/v1/hosted-rooms/:n` | owner (or moderator console): closes it; everyone in it is told (`room.closed`). |
A room: `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
createdAt}` (`ownerName`: the owner's IMVU name as linked; `occupancy`: people in it now). A room whose owner account
is no longer linked to its owner (unlinked, or moved to another person), or whose owner is banned, is hidden (not
listed, `not_found`) until it's linked again. Closed rooms stay closed; their numbers aren't reused.
The scene's `contents` is the client's `room_contents`: a list of `[furniture instance ID (integer), product ID
(integer or a hub product ID string), placement (string)]`, up to 500 items, placement up to 200 characters. `hash`
is a hash of the stored scene (the client caches scenes by it): sha256, hex, of `[roomPid, contents]` as JSON.
## Realtime (the proxy's WebSocket)
From the proxy (the session's IMVU account, sent with `session`, must be linked to the person):
- `{"type": "room.join", "room": n}`: join. Answer `{"type": "room.joined", "room": n, "chat": chatId,
"participants": [{"imvu_id", "seat"}], "sequence": s}` (the room's latest message number), or
`{"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" | "not_linked" | "too_many",
"message": "..."}` (`too_many`: the session is in 5 hosted rooms already). Joining again (a reconnect) answers the
same. A newcomer's seat is 0 until it sends `room.seat`. `maxUsers` counts IMVU accounts, so the same account
joining from a second session isn't refused as full.
- `{"type": "room.leave", "room": n}`.
- `{"type": "room.seat", "room": n, "seat": k}`: the person sat down (`*seat k`); 0-99.
- `{"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}` (up to 64 KB): to everyone
else in the room. For `messages` the hub checks the JSON's `userId` is the sender's IMVU account and drops it
otherwise (silently). Each session may send bursts of 20, then 10 a second; more is dropped. Malformed messages
(bad room, seat, mount, a message over 64 KB, sending to a room it isn't in) get `{"type": "error", "message"}`.
From the hub:
- `{"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}`: numbered
per room, from 1.
- `{"type": "room.participants", "room": n, "participants": [{"imvu_id", "seat"}]}`: whenever someone joins, leaves
or sits (not to the one joining: `room.joined` has the list).
- `{"type": "room.closed", "room": n}`: the room closed, or this session is out of it: its IMVU account was unlinked
or it sent `session` with another account, or the room became adult and its account isn't. (A lower `maxUsers`
sends no one out; it only stops newcomers.)
A session closing leaves its rooms. The hub keeps the participants and message numbers in memory; messages aren't
stored (design.md: chat retention).
## The proxy's part
- **XML-RPC** for hosted IDs, answered by the proxy (never sent to IMVU): `test.getRoomCardInfo`,
`chat.getOrMakeChat` (activity `publicroom-<owner>-<2100000000+n>`: joins, answers `chatId`, `seat`, `lastMessageId`,
`participantUrl`), `test.getRoomState` (the scene, with community products as local IDs), `chat.getParticipants`,
`chat.terminateChat`, `test.setRoomState` (owners: saved with `PUT .../scene`).
- **IMQ**: subscribing to `/chat/<chatId>` of a hosted room is answered by the proxy (result, `JoinedQueue`,
`CreateMount`s for `control`, `messages`, `participants`); `SendMessage` on it goes to the hub as `room.send`, and
`room.message` comes back as `G2cSendMessage`. `inv:/room/room-<owner>-<2100000000+n>` is answered with a result and a
`node` mount, and nothing more.
- **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/<owner>-<2100000000+n>`
queues are answered from `room.participants`.
## Phase 2
Moderators, boots and bans, private rooms, invitations, images, favorites, ratings, recent rooms, occupancy pushed
to watchers, and participants' names. Everything in phase 1 still holds; this adds to it.
### Rooms (REST)
A room (in every answer that has one) also has:
| Field | |
| --- | --- |
| `privacy` | `"public"` (default) or `"private"` |
| `moderators` | IMVU account IDs, up to 20, in the order the owner gave them (never the owner's own account) |
| `moderatorNames` | their IMVU names, same order: the name of the linked account, or as IMVU gave it when added by name; `""` when not known |
| `image` | `<API URL>/api/v1/hosted-rooms/<n>/image?v=<16 hex>` (absolute; `v` changes with the image), or `null` |
| `favorite` | whether it's one of the signed-in person's favorites (`false` signed out) |
| `score` | the average rating, 1-5, two decimals; `0` if nobody rated it. (Named `score`, not `rating`: `rating` is already the room's `general`/`adult`.) |
| `ratings` | how many people rated it |
Who's who: the room's **owner** is, over REST, the person who made it; over realtime, its owner IMVU account
(`ownerImvuId`). A **moderator** is an IMVU account in `moderators`; over REST, a person is a moderator if one of their
linked accounts is.
| Route | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | also takes `privacy?` |
| `PATCH /api/v1/hosted-rooms/:n` | (owner) also takes `privacy?` and `moderators?`: up to 20 entries, each an IMVU ID (integer, or a string of digits) or an IMVU name (looked up: linked accounts first, then IMVU, rate-limited; unknown: 404 `no_such_imvu_account`). Replaces the list; duplicates and the owner's account are dropped. More than 20: 400 `invalid`. Making a room private sends no one out. |
| `GET /api/v1/hosted-rooms` | public rooms, and private ones the person owns or moderates. `?mine=1`: the person's own (private too). `?favorites=1`: their favorites, latest starred first. `?recent=1`: the last 20 distinct rooms they joined (any of their accounts), latest first. Those three need a signed-in person (401 `signed_out`); adult rooms only for adults, as before; closed and hidden rooms left out. |
| `GET /api/v1/hosted-rooms/:n` | anyone may get a private room (it's unlisted, not secret). Also `myRating`: the person's own rating, `0` if none or signed out. |
| `GET /api/v1/hosted-rooms/:n/participants` | (anyone who may `GET /:n`, also signed out; same 404/410) `{participants: [{imvu_id, seat, name}]}`, as in `room.participants` |
| `GET /api/v1/hosted-rooms/:n/bans` | (owner or moderator) `{bans: [{imvuId, byImvuId, until, createdAt}]}`: bans in force, latest first; `until` ISO time or `null` (for good). Others: 403 `forbidden`. |
| `DELETE /api/v1/hosted-rooms/:n/bans/:imvu` | (owner only) lifts a ban: 204; 404 if there's none. Logged (`hosted_room.unban`). |
| `PUT /api/v1/hosted-rooms/:n/image` | (owner) multipart, field `image`: a PNG or JPEG (by its magic bytes, whatever the declared type), up to 512 KB; else 400 `invalid`. Stored as uploaded. → `{room}`. 30 an hour. |
| `DELETE /api/v1/hosted-rooms/:n/image` | (owner) → `{room}` with `image: null` |
| `GET /api/v1/hosted-rooms/:n/image` | (anyone who may `GET /:n`) the image, with its `Content-Type`, `ETag` (`If-None-Match` answers 304), `Cache-Control: public, max-age=3600` (`private` for adult rooms), `X-Content-Type-Options: nosniff`; 404 if none |
| `PUT /api/v1/hosted-rooms/:n/favorite` | (signed in, a room they may see) stars it: 204, also if starred already |
| `DELETE /api/v1/hosted-rooms/:n/favorite` | 204 |
| `PUT /api/v1/hosted-rooms/:n/rating` | `{score}` (integer 1-5) → `{room, myRating}`; replaces their earlier rating. Owners can't rate their own room (403 `forbidden`). |
| `DELETE /api/v1/hosted-rooms/:n/rating` | removes their rating: 204 |
A visit is recorded when an IMVU account joins a hosted room (a new member; not a re-join of a session already in
it): per person, the room, the account and the time; the latest 50 per person are kept.
### Realtime
**Participants** everywhere (`room.joined`, `room.participants`, `room.occupancy`, `GET .../participants`) are
`{"imvu_id": X, "seat": k, "name": "..."}`; `name` is the IMVU username of the hub's verified link for that account
(as of when it joined), `""` if unknown.
**Joining** (`room.join`) may carry `"invite": id`. New `room.error` reasons, checked in this order after phase 1's
`not_linked`, `not_found`, `closed`, `adult`: `"banned"` (a ban in force on the session's account), then `"private"`
(a private room, and the account isn't its owner account, a moderator, already let in, or holding an accepted,
unexpired invite for this room and account), then `too_many`, `full`.
An invite used to join is spent, and the account is **let in**: it may join again without one (a reconnect, another
session) until it sends `room.leave` and none of its sessions is still in the room, or it's booted. (A connection
dropping doesn't end it. It's kept in memory: a hub restart ends it.)
**Boot and ban**, from the owner account or a moderator (needn't be in the room):
- proxy → hub `{"type": "room.boot", "room": n, "imvu_id": X, "ban"?: false, "minutes"?: 0}`. `ban` true stores a
ban; `minutes` 0 (default) is for good, else 1 to 5,270,400 (about ten years). Malformed: `{"type": "error",
"message": "bad imvu_id" | "bad ban" | "bad minutes" | "bad room"}`.
- hub → proxy (the sender) `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": true, "ban": bool}`, or
`{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": false, "reason": "...", "message": "..."}` with
`reason` `"not_linked"`, `"not_found"`, `"closed"` or `"forbidden"`: the sender isn't the owner account or a
moderator; X is the owner account (nobody boots the owner); X is a moderator and the sender isn't the owner
account; or X is the sender.
- Every session of X in the room gets `{"type": "room.closed", "room": n, "reason": "booted"}` and is out; the others
get `room.participants`; watchers `room.occupancy`. A boot without a ban doesn't stop X coming back (except into
a private room, which needs a new invite).
- Logged: `hosted_room.boot` or `hosted_room.ban`, target the room number, detail `{imvu: X, by: sender's IMVU ID}`
(and `minutes` for a ban), actor the sender's person. A banned moderator stays a moderator (the owner removes them
from `moderators`).
**Invitations**:
- proxy → hub `{"type": "room.invite", "room": n, "imvu_id": X}`: from a session in the room (public rooms), or from
the owner account or a moderator, in it or not (private rooms). 10, then 10 a minute per session.
- hub → proxy (the sender) `{"type": "room.invite_result", "room": n, "imvu_id": X, "ok": true, "invite": id}`, or
`ok: false` with `reason` `"not_linked"`, `"not_found"`, `"closed"`, `"forbidden"` (private room, not owner or
moderator), `"not_in_room"` (public room, the sender isn't in it), `"banned"` (X is banned from the room),
`"adult"` (an adult room, and none of X's sessions counts as adult) or `"rate_limited"`, and `message`. X being the
sender: `{"type": "error", "message": "bad imvu_id"}`.
- When `ok`: every session whose IMVU account is X gets `{"type": "room.invited", "room": n, "invite": id, "from":
sender's IMVU ID, "room_name": "...", "owner_name": "..."}` (`owner_name`: the owner account's IMVU name). If X has
no session, the sender instead gets `{"type": "room.invite_answer", "invite": id, "to": X, "accept": false,
"offline": true}` right after the result, and the invite is dropped.
- proxy (X's) → hub `{"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."}`. The first answer
counts. `note`: control characters become spaces, trimmed, up to 200 characters, left out when empty. Errors:
`{"type": "error", "message": "no such invite" | "already answered" | "bad accept" | "bad note"}` (`no such invite`
also when it expired or the session's account isn't X).
- hub → proxy (every session of the sender's IMVU account) `{"type": "room.invite_answer", "invite": id, "to": X,
"accept": bool, "note"?: "..."}`.
- An invite: single use, valid 10 minutes from when it was made (accepting doesn't extend it), for that room and
account. Kept in memory only; closing the room drops its invites. `invite` ids are opaque strings (base64url).
**Occupancy**:
- proxy → hub `{"type": "room.watch", "rooms": [n, ...]}`: replaces the session's watched set (up to 100; `[]`
stops). Not a list of room numbers, or over 100: `{"type": "error", "message": "bad rooms"}`. Rooms the session
may not see are left out silently: no such room, closed or hidden; adult rooms for a session whose account isn't
adult; private rooms unless its account is the owner account or a moderator. That's checked when it watches.
- hub → proxy, once per watched room right after, then whenever the room's participants change (join, leave, seat,
boot): `{"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}`. A room closing sends
`participants: []` and stops watching it. A session closing stops its watching.
### The proxy's part (phase 2)
- Room cards and lists: `privacy`, `image`, `score`/`ratings` from the room views; people in a room by name from
`participants` (`name`), or `GET .../participants` for a room it isn't in or watching.
- `room_occupancy/<owner>-<2100000000+n>` queues: from `room.watch`/`room.occupancy` instead of polling.
+120
View File
@@ -0,0 +1,120 @@
# Products, catalogs and the library: the contract
How the hub (registry), catalogs (`t5de-catalog`, file hosting) and T5DE's proxy work together. The design and the
reasons are in `design.md` ("Products and catalogs", "Revisions", "In the client's shop and inventory"); this is
what each side sends.
## Words
- **Product**: a T5 product, owned by its creator (a hub person). ID: 10 characters of `a-z2-7` (random), e.g.
`k3mq7xa2pd`. Valid as a pack ID, so the proxy can store a downloaded product as a pack.
- **Revision**: a product's files, numbered 1, 2, 3... Never changes. Its **file list** is
`[{name, sha256, size}]`, sorted by name; `sha256` is lowercase hex.
- **Catalog**: a server storing files by sha256 and serving them. Registered on the hub; hidden until a hub
moderator approves it.
- **Library**: the products a person "got" (free), per person (all their IMVU accounts), optionally staying on a
revision.
## Tokens
The hub signs tokens with its own Ed25519 key (EdDSA JWTs, compact form), published as a JWKS at
`GET /.well-known/t5de-keys.json` (`kid` in each token's header). Catalogs fetch it (and again when they see an
unknown `kid`). Claims always include `iss` (the hub's API URL), `typ` (below) and `iat`.
| `typ` | Other claims | Lifetime | Use |
| --- | --- | --- | --- |
| `t5de-upload` | `sub` (person ID), `catalog` (catalog ID), `product` (optional: a new revision of this product) | 30 min | the creator's browser uploads to the catalog |
| `t5de-access` | `sub` (person ID), `adult` (bool) | 1 h | the proxy fetches adult files from catalogs |
| `t5de-revision` | `product`, `revision`, `rating`, `files` (the file list), `parent` (IMVU product ID or 0) | none (`exp` absent) | the hub vouches for a revision's files |
## Catalog API (`t5de-catalog`)
Served at the catalog's public URL. CORS: the hub website's origin may call `POST /api/v1/uploads`.
| | |
| --- | --- |
| `GET /health` | `{ok: true}` |
| `POST /api/v1/uploads` | `Authorization: Bearer <t5de-upload token>`; multipart form: `file` (the product as a zip: Create Mode's `.chkn`, files at the root) and `notes` (text, required for a new revision of an existing product, optional for the first). The catalog validates the files (t5de-api `pack` rules: names, limits, `index.xml` with `ProductRating` GA/AP), stores each file by sha256, then registers the revision with the hub (below) and answers the hub's answer: 201 `{product, revision}`. Errors: `{error, message}`, 400 for bad files, 401 for a bad token, 502 if the hub refused or failed. |
| `GET /api/v1/files/<sha256>` | the file's bytes, `Cache-Control: public, max-age=31536000, immutable` and `ETag` = the hash. A file used only by adult revisions needs `Authorization: Bearer <t5de-access token>` with `adult: true` (else 401/403, and `Cache-Control: private`). 404 if the catalog doesn't have it. |
| `GET /api/v1/revisions/<product>/<revision>` | `{product, revision, rating, files}`, for revisions the catalog hosts |
Configuration (environment): `CATALOG_ADDR` (listen address, default `127.0.0.1:4102`), `CATALOG_DATA` (data folder:
SQLite database and files), `CATALOG_PUBLIC_URL`, `HUB_URL` (the hub's API), `CATALOG_ID` and `CATALOG_KEY` (from
registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
## Hub API
### For catalogs (server to server)
`Authorization: Catalog <catalog ID>:<key>`. Only approved catalogs.
| | |
| --- | --- |
| `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision}`. The upload token must be for this catalog. Without `product` in the token, a new product is made (owner: the token's person, name from `name`). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). |
### For people (website, signed in; the proxy with its bearer token)
| | |
| --- | --- |
| `POST /api/v1/catalogs` | register a catalog: `{name, url, contact, visibility ("public"/"private"), membership ("open"/"approval"/"invite")}` → 201 `{catalog, key}` (the key is shown once) |
| `GET /api/v1/catalogs` | (anyone) approved public catalogs; the person's own, any status |
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL |
| `GET /api/v1/products?q=&creator=&category=&limit=` | (anyone) `{products}`, newest first; adult ones only for people with an adult account. `category`: an IMVU category ID; only products whose latest revision's parent has it anywhere in its category path (e.g. 106 Clothing, 128 Tops). `limit`: 1-200, default 60 |
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after. 204 |
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
| `PUT /api/v1/library/:id` | get a product (add it) |
| `DELETE /api/v1/library/:id` | remove it |
| `PATCH /api/v1/library/:id` | `{stayOn: n or null}` |
| `GET /api/v1/products/:id/files` | for the proxy: `{product, revision, rating, parent, name, files, catalogs: [url], token}` for the revision this person uses (their pin, else the latest that isn't retracted); `token` is the `t5de-revision` token |
| `POST /api/v1/access` | for the proxy: `{token}`, a `t5de-access` token (`adult` if any of the person's IMVU accounts counts as adult) |
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes.
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
parent, image, categories, createdAt, updatedAt}`. `parent`, `image` and `categories` are the latest revision's:
`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and
`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if
unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`).
### Parent info
The hub looks up a revision's parent when the revision is registered (after it's stored; an upload never waits for or
fails on it) and stores it with the revision (`product_revision.parent_info`: `{found, image, categories, gender,
checkedAt}`; `found` is false when IMVU shows no such product publicly). Info another revision got for the same
parent in the last 7 days is reused. Revisions still without it (IMVU was unavailable) are looked up at startup and
every 10 minutes, 20 at a time.
### Console (moderators)
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
## The proxy
- Fetches the library at connect and on `library` messages; for each product, `GET /api/v1/products/:id/files`,
downloads missing files from a listed catalog (`GET <catalog>/api/v1/files/<sha256>`, with an access token for
adult revisions), checks each sha256, and writes the product as a pack in its cache
(`%APPDATA%\T5DE\proxy\hub-products\<id>\`: `t5de-product.json` with the hub's name and rating, and `files/`).
From there it's served like any pack.
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client.
- Library products appear in the client's inventory (see the proxy's README).
## Decorations
T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU
keeps the room without them; the hub keeps them (`room_decoration`, routes in api.md), and only T5DE users see them.
- **Saving**: when the room's owner saves the room (`test.setRoomState`) or the client broadcasts a furniture change
in it, the proxy takes the hub products out before it goes to IMVU, and `PUT /api/v1/decorations/<instance>` the
room's full list of them (`[furniture instance ID, hub product ID, placement]`).
- **Loading**: when the client loads an IMVU room (`test.getRoomState`), the proxy `GET`s its decorations and adds
them to the room's contents (as local IDs, fetching the products as for outfits); the `hash` tells it whether
what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back
the same way.
- **Changes**: after a `PUT`, every proxy with a chat in that room gets `{"type": "decor", "room": instance}` on the
realtime connection and `GET`s again.
- Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for
adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room.
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE "age_check" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"imvu_id" bigint,
"provider" text NOT NULL,
"method" text DEFAULT '' NOT NULL,
"status" text NOT NULL,
"over_eighteen" boolean DEFAULT false NOT NULL,
"provider_ref" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"checked_at" timestamp with time zone,
"expires_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "age_check" ADD CONSTRAINT "age_check_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "age_check_user" ON "age_check" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "age_check_imvu" ON "age_check" USING btree ("imvu_id");--> statement-breakpoint
CREATE UNIQUE INDEX "age_check_provider_ref" ON "age_check" USING btree ("provider","provider_ref");--> statement-breakpoint
-- Existing links' IMVU results become IMVU age checks (expiring at once, so they're checked again soon).
INSERT INTO "age_check" ("id", "user_id", "imvu_id", "provider", "method", "status", "over_eighteen", "checked_at", "expires_at")
SELECT md5(random()::text || "imvu_id"::text), "user_id", "imvu_id", 'imvu',
CASE WHEN "age_verified" THEN 'imvu_age_verified' WHEN "access_pass" THEN 'imvu_access_pass' ELSE 'imvu_none' END,
'complete', "age_verified" OR "access_pass", "checked_at", now()
FROM "imvu_link";--> statement-breakpoint
ALTER TABLE "imvu_link" DROP COLUMN "age_verified";--> statement-breakpoint
ALTER TABLE "imvu_link" DROP COLUMN "access_pass";--> statement-breakpoint
ALTER TABLE "imvu_link" DROP COLUMN "checked_at";
+19
View File
@@ -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");
+72
View File
@@ -0,0 +1,72 @@
CREATE TABLE "catalog" (
"id" text PRIMARY KEY NOT NULL,
"owner_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"contact" text NOT NULL,
"visibility" text DEFAULT 'public' NOT NULL,
"membership" text DEFAULT 'open' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"key_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hosting" (
"catalog_id" text NOT NULL,
"product_id" text NOT NULL,
"number" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosting_catalog_id_product_id_number_pk" PRIMARY KEY("catalog_id","product_id","number")
);
--> statement-breakpoint
CREATE TABLE "library" (
"user_id" text NOT NULL,
"product_id" text NOT NULL,
"stay_on" integer,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "library_user_id_product_id_pk" PRIMARY KEY("user_id","product_id")
);
--> statement-breakpoint
CREATE TABLE "product" (
"id" text PRIMARY KEY NOT NULL,
"creator_id" text NOT NULL,
"name" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"rating" text NOT NULL,
"latest" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "product_revision" (
"product_id" text NOT NULL,
"number" integer NOT NULL,
"files" jsonb NOT NULL,
"rating" text NOT NULL,
"parent" bigint DEFAULT 0 NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"token" text NOT NULL,
"uploaded_by" text,
"retracted" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "product_revision_product_id_number_pk" PRIMARY KEY("product_id","number")
);
--> statement-breakpoint
CREATE TABLE "signing_key" (
"id" text PRIMARY KEY NOT NULL,
"public_key" jsonb NOT NULL,
"private_key" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "catalog" ADD CONSTRAINT "catalog_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosting" ADD CONSTRAINT "hosting_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library" ADD CONSTRAINT "library_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library" ADD CONSTRAINT "library_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product" ADD CONSTRAINT "product_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "catalog_url" ON "catalog" USING btree ("url");--> statement-breakpoint
CREATE INDEX "hosting_product" ON "hosting" USING btree ("product_id");--> statement-breakpoint
CREATE INDEX "product_creator" ON "product" USING btree ("creator_id");--> statement-breakpoint
CREATE INDEX "product_created" ON "product" USING btree ("created_at");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE "hosted_room" (
"n" serial PRIMARY KEY NOT NULL,
"owner_id" text NOT NULL,
"owner_imvu_id" bigint NOT NULL,
"name" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"room_pid" bigint NOT NULL,
"rating" text DEFAULT 'general' NOT NULL,
"max_users" integer DEFAULT 10 NOT NULL,
"scene" jsonb DEFAULT '[]'::jsonb NOT NULL,
"scene_hash" text DEFAULT '' NOT NULL,
"closed" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "hosted_room" ADD CONSTRAINT "hosted_room_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "hosted_room_owner" ON "hosted_room" USING btree ("owner_id");--> statement-breakpoint
CREATE INDEX "hosted_room_created" ON "hosted_room" USING btree ("created_at");
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE "room_decoration" (
"instance_id" text PRIMARY KEY NOT NULL,
"owner_imvu_id" bigint NOT NULL,
"contents" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id");
@@ -0,0 +1,53 @@
CREATE TABLE "hosted_room_ban" (
"n" integer NOT NULL,
"imvu_id" bigint NOT NULL,
"by_imvu_id" bigint NOT NULL,
"until" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_favorite" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_image" (
"n" integer PRIMARY KEY NOT NULL,
"content_type" text NOT NULL,
"data" "bytea" NOT NULL,
"hash" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hosted_room_rating" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"score" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_visit" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"imvu_id" bigint NOT NULL,
"at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint
ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint
CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at");
@@ -0,0 +1,3 @@
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
+999
View File
@@ -0,0 +1,999 @@
{
"id": "6311568f-0471-47a9-8199-229d365d3dc4",
"prevId": "47a7aacf-4e5f-4181-9ac1-0100ba416d0c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"account_user": {
"name": "account_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.age_check": {
"name": "age_check",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imvu_id": {
"name": "imvu_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"method": {
"name": "method",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"over_eighteen": {
"name": "over_eighteen",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"provider_ref": {
"name": "provider_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"checked_at": {
"name": "checked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"age_check_user": {
"name": "age_check_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"age_check_imvu": {
"name": "age_check_imvu",
"columns": [
{
"expression": "imvu_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"age_check_provider_ref": {
"name": "age_check_provider_ref",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "provider_ref",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"age_check_user_id_user_id_fk": {
"name": "age_check_user_id_user_id_fk",
"tableFrom": "age_check",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit": {
"name": "audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"at": {
"name": "at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"detail": {
"name": "detail",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {
"audit_at": {
"name": "audit_at",
"columns": [
{
"expression": "at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_actor_id_user_id_fk": {
"name": "audit_actor_id_user_id_fk",
"tableFrom": "audit",
"tableTo": "user",
"columnsFrom": [
"actor_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.device_code": {
"name": "device_code",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"device_code": {
"name": "device_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_code": {
"name": "user_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_polled_at": {
"name": "last_polled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"polling_interval": {
"name": "polling_interval",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"device_code_user_id_user_id_fk": {
"name": "device_code_user_id_user_id_fk",
"tableFrom": "device_code",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.imvu_link": {
"name": "imvu_link",
"schema": "",
"columns": {
"imvu_id": {
"name": "imvu_id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"pairwise_id": {
"name": "pairwise_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"linked_at": {
"name": "linked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"show_badge": {
"name": "show_badge",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"show_presence": {
"name": "show_presence",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {
"imvu_link_user": {
"name": "imvu_link_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"imvu_link_user_id_user_id_fk": {
"name": "imvu_link_user_id_user_id_fk",
"tableFrom": "imvu_link",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"imvu_link_pairwise_id_unique": {
"name": "imvu_link_pairwise_id_unique",
"nullsNotDistinct": false,
"columns": [
"pairwise_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jwks": {
"name": "jwks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"alg": {
"name": "alg",
"type": "text",
"primaryKey": false,
"notNull": false
},
"crv": {
"name": "crv",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.link_challenge": {
"name": "link_challenge",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imvu_id": {
"name": "imvu_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"link_challenge_user_id_user_id_fk": {
"name": "link_challenge_user_id_user_id_fk",
"tableFrom": "link_challenge",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"link_challenge_user_id_imvu_id_pk": {
"name": "link_challenge_user_id_imvu_id_pk",
"columns": [
"user_id",
"imvu_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"session_user": {
"name": "session_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"link_cap": {
"name": "link_cap",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
}
},
"indexes": {
"user_username_lower": {
"name": "user_username_lower",
"columns": [
{
"expression": "lower(\"username\")",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier": {
"name": "verification_identifier",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+56
View File
@@ -8,6 +8,62 @@
"when": 1793273462704, "when": 1793273462704,
"tag": "0000_init", "tag": "0000_init",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1793277687785,
"tag": "0001_age_checks",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1793321456968,
"tag": "0002_rooms",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1793324955738,
"tag": "0003_products",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1793347080470,
"tag": "0004_parent_info",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1793355138984,
"tag": "0005_hosted_rooms",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1793357692630,
"tag": "0006_decorations",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1793364727935,
"tag": "0007_hosted_rooms_phase2",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1793372872749,
"tag": "0008_product_deletion",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema"; import * as schema from "./schema";
export { schema }; export { schema };
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
export * from "drizzle-orm"; export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+303 -3
View File
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
import { import {
bigint, bigint,
boolean, boolean,
customType,
index, index,
integer, integer,
jsonb, jsonb,
@@ -137,15 +138,63 @@ export const imvuLink = pgTable(
pairwiseId: text("pairwise_id").notNull().unique(), pairwiseId: text("pairwise_id").notNull().unique(),
username: text("username").notNull(), username: text("username").notNull(),
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(), linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
ageVerified: boolean("age_verified").notNull().default(false),
accessPass: boolean("access_pass").notNull().default(false),
checkedAt: timestamp("checked_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
// birth date, document, photo or name (providers that see those keep them).
export const ageCheck = pgTable(
"age_check",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person.
imvuId: bigint("imvu_id", { mode: "number" }),
provider: text("provider").notNull(), // "imvu", "didit", "wallet", ...
method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc"
status: text("status").notNull(), // "pending", "complete", "failed", "revoked"
overEighteen: boolean("over_eighteen").notNull().default(false),
providerRef: text("provider_ref"), // the provider's session or transaction ID
createdAt: createdAt(),
checkedAt: timestamp("checked_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
},
(t) => [
index("age_check_user").on(t.userId),
index("age_check_imvu").on(t.imvuId),
uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef),
],
);
// A code the person is to put in an IMVU account's tagline, proving it's theirs. // A code the person is to put in an IMVU account's tagline, proving it's theirs.
export const linkChallenge = pgTable( export const linkChallenge = pgTable(
"link_challenge", "link_challenge",
@@ -173,3 +222,254 @@ export const audit = pgTable(
}, },
(t) => [index("audit_at").on(t.at)], (t) => [index("audit_at").on(t.at)],
); );
// ---- Products (docs/products.md) ----
export type FileEntry = { name: string; sha256: string; size: number };
/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */
export type ParentInfo = {
found: boolean; // false: IMVU shows no such product publicly
image: string | null; // https, on *.imvu.com
categories: { id: number; name: string }[]; // outermost first
gender: string; // "female", "male" or ""
checkedAt: string; // ISO time
};
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
export const signingKey = pgTable("signing_key", {
id: text("id").primaryKey(), // the JWK's kid
publicKey: jsonb("public_key").$type<Record<string, string>>().notNull(),
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
createdAt: createdAt(),
});
// A catalog: a server hosting product files. Hidden until a moderator approves it.
export const catalog = pgTable(
"catalog",
{
id: text("id").primaryKey(),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
url: text("url").notNull(), // its public URL
contact: text("contact").notNull(), // takedown and legal contact
visibility: text("visibility").notNull().default("public"), // "public", "private"
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
createdAt: createdAt(),
},
(t) => [uniqueIndex("catalog_url").on(t.url)],
);
// A product: its info, owned by its creator. Its files are in revisions.
export const product = pgTable(
"product",
{
id: text("id").primaryKey(), // 10 of a-z2-7
creatorId: text("creator_id")
.notNull()
.references(() => user.id, { onDelete: "restrict" }),
name: text("name").notNull(),
description: text("description").notNull().default(""), // HTML, already cleaned
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
latest: integer("latest").notNull().default(0), // the newest revision's number
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
removedAt: timestamp("removed_at", { withTimezone: true }),
removedReason: text("removed_reason"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
);
// A revision: a product's files. Never changes (but can be retracted).
export const productRevision = pgTable(
"product_revision",
{
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
number: integer("number").notNull(),
files: jsonb("files").$type<FileEntry[]>().notNull(),
rating: text("rating").notNull(),
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
notes: text("notes").notNull().default(""),
token: text("token").notNull(), // the hub's t5de-revision token
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
retracted: boolean("retracted").notNull().default(false),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.productId, t.number] })],
);
// Which catalogs host which revisions.
export const hosting = pgTable(
"hosting",
{
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
productId: text("product_id").notNull(),
number: integer("number").notNull(),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)],
);
// A person's library: products they got, optionally staying on a revision.
export const library = pgTable(
"library",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
stayOn: integer("stay_on"),
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
);
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
export type SceneItem = [number, number | string, string];
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
export const hostedRoom = pgTable(
"hosted_room",
{
n: serial("n").primaryKey(),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
name: text("name").notNull(),
description: text("description").notNull().default(""), // plain text
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
rating: text("rating").notNull().default("general"), // "general", "adult"
maxUsers: integer("max_users").notNull().default(10),
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
sceneHash: text("scene_hash").notNull().default(""),
closed: boolean("closed").notNull().default(false),
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
privacy: text("privacy").notNull().default("public"),
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
imageHash: text("image_hash"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
);
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
export type RoomModerator = { imvuId: number; username: string };
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
dataType: () => "bytea",
fromDriver: (v) => Buffer.from(v),
});
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
export const hostedRoomImage = pgTable("hosted_room_image", {
n: integer("n")
.primaryKey()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
data: bytea("data").notNull(),
hash: text("hash").notNull(), // sha256, hex
updatedAt: updatedAt(),
});
// IMVU accounts banned from a hosted room; until null: for good.
export const hostedRoomBan = pgTable(
"hosted_room_ban",
{
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
until: timestamp("until", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
);
// A person's favorite hosted rooms.
export const hostedRoomFavorite = pgTable(
"hosted_room_favorite",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] })],
);
// A person's rating of a hosted room, 1-5.
export const hostedRoomRating = pgTable(
"hosted_room_rating",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
score: integer("score").notNull(),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
);
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
export const hostedRoomVisit = pgTable(
"hosted_room_visit",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
);
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
export type DecorationItem = [number, string, string];
// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from
// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in
// hosted_room.scene instead.
export const roomDecoration = pgTable(
"room_decoration",
{
instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "<owner>-<n>"
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
updatedAt: updatedAt(),
},
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
);