Compare commits
22
Commits
7afada1c63
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e1bd8790 | ||
|
|
975e11e077 | ||
|
|
7d20426d1b | ||
|
|
e3770ceb57 | ||
|
|
97ce2be569 | ||
|
|
07bbc11565 | ||
|
|
76c8e0342e | ||
|
|
43183ec7ee | ||
|
|
f65f36c1ea | ||
|
|
2a1fc94df6 | ||
|
|
0c67232425 | ||
|
|
6668b55117 | ||
|
|
d57674d90c | ||
|
|
51d5bb5ff3 | ||
|
|
b4f6fce9df | ||
|
|
7fea63fc4f | ||
|
|
143aeb3d84 | ||
|
|
6ba4d83317 | ||
|
|
7db9fc0370 | ||
|
|
339ba8e2d6 | ||
|
|
7a06ba49c3 | ||
|
|
64c552330b |
No files matched your search
+51
-20
@@ -1,39 +1,70 @@
|
|||||||
# Tests the hub (against a real Postgres, a service container here) and builds it.
|
# 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:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
GOPRIVATE: git.t5de.dev
|
|
||||||
# Jobs run as root, which embedded Postgres refuses: the tests use this service instead.
|
|
||||||
T5DE_TEST_DB: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 5s --health-retries 30
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
bun-version: 1.4.4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 26
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check types
|
||||||
|
run: bun run check-types
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: bun run test
|
||||||
unformatted=$(gofmt -l .)
|
|
||||||
if [ -n "$unformatted" ]; then echo "Not gofmt-formatted: $unformatted"; exit 1; fi
|
|
||||||
go vet ./...
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: CGO_ENABLED=0 go build -trimpath -o dist/t5de-hub ./cmd/t5de-hub
|
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
|
||||||
+8
-2
@@ -1,2 +1,8 @@
|
|||||||
/dist/
|
node_modules/
|
||||||
/t5de-hub-data/
|
.next/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
apps/api/data/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# t5de-hub
|
# t5de-hub
|
||||||
|
|
||||||
T5DE's hub (see docs/design.md, a draft under discussion with the user). Related repositories, usually checked out
|
T5DE's hub (see README.md and docs/design.md). Related repositories, usually checked out beside this one: `t5de`
|
||||||
beside this one: `t5de` (patches), `t5de-proxy` (local proxy), later `t5de-catalog` and `t5de-api`.
|
(patches), `t5de-proxy` (local proxy, Go; it signs in to the hub), `t5de-api` (shared definitions), later
|
||||||
|
`t5de-catalog`.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -9,15 +10,21 @@ beside this one: `t5de` (patches), `t5de-proxy` (local proxy), later `t5de-catal
|
|||||||
in our own words are fine.
|
in our own words are fine.
|
||||||
- Test with a throwaway IMVU account, never a real one.
|
- Test with a throwaway IMVU account, never a real one.
|
||||||
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
|
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
|
||||||
- The user prioritizes backend performance (Go), and wants moderation centered on the hub's website.
|
|
||||||
- IMVU session keys never leave the user's machine; the hub never asks for them.
|
- IMVU session keys never leave the user's machine; the hub never asks for them.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Go, net/http, html/template (server-rendered; htmx 2.0.4 vendored in `internal/web/static`, CSP without inline
|
- Stack (the user's choice, 2026-10-29): Bun, Elysia, Better Auth, Drizzle (Postgres; PGlite in development and
|
||||||
scripts or styles), pgx with plain SQL (`internal/store`, migrations embedded), coder/websocket.
|
tests), Next.js 16 with Tailwind 4 and shadcn/ui, Turborepo. Must stay self-hostable (docker compose) and deployable
|
||||||
- Dev: `go run ./cmd/t5de-hub -dev` (embedded Postgres on port 54321). Tests use port 54339 and a database each.
|
to Vercel/Heroku-style hosts. Go was used first and replaced.
|
||||||
- The look: IMVU Classic's dialog palette (dark grays, gold buttons with black text), like T5DE's setup. Copy is
|
- The website's look: modern and neutral, light/dark following the system, color only for status. Not IMVU Classic's
|
||||||
short and plain.
|
style (T5DE's setup keeps that).
|
||||||
- Calls to IMVU (`internal/imvu`, its public user API) are rare and rate-limited: linking and age re-checks only.
|
- Better Auth's tables must match its plugins: when changing plugins, compare with `getAuthTables` (Better Auth logs
|
||||||
|
"Drizzle schema mismatch" otherwise), then `bun x drizzle-kit generate` in packages/db.
|
||||||
|
- Device sign-in: the person's viewing a code (`deviceVerify`) claims it, then `deviceApprove`; the hub's
|
||||||
|
`/api/v1/device/approve` does both and logs it. Proxies' sessions are told apart by their User-Agent
|
||||||
|
(`t5de-proxy...`).
|
||||||
|
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
|
||||||
|
`bunx`: use `bun x`).
|
||||||
|
- 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).
|
||||||
@@ -2,32 +2,54 @@
|
|||||||
|
|
||||||
T5DE's hub: accounts linked to IMVU accounts, presence, and (to come) the registry of community products and
|
T5DE's hub: accounts linked to IMVU accounts, presence, and (to come) the registry of community products and
|
||||||
catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.dev/t5de). Design:
|
catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.dev/t5de). Design:
|
||||||
[docs/design.md](docs/design.md); what a room needs from a server: [docs/imq-rooms.md](docs/imq-rooms.md).
|
[docs/design.md](docs/design.md); the API: [docs/api.md](docs/api.md); what a room needs from a server:
|
||||||
|
[docs/imq-rooms.md](docs/imq-rooms.md).
|
||||||
|
|
||||||
## What works
|
## What works
|
||||||
|
|
||||||
- **Accounts**: sign-up and sign-in (Argon2id passwords), sessions with CSRF protection. The first account is an
|
- **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
|
||||||
admin.
|
an admin; roles user, moderator, admin.
|
||||||
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
|
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
|
||||||
API. The most recent proof wins (an account linked elsewhere moves). Link caps per person (default 10).
|
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
|
||||||
- **Age**: an account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub.
|
(default 10).
|
||||||
- **Proxy sign-in**: OAuth 2.0 device codes (`t5de-proxy signin` shows a code to enter at `/device`); Ed25519 JWT
|
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub.
|
||||||
access tokens (keys at `/.well-known/jwks.json`) and rotating refresh tokens.
|
- **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the
|
||||||
|
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 accounts that opted in, 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.
|
||||||
- **Console** (moderators): counts, who's online (live), people search, link caps, bans, roles, audit log.
|
- **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.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
A Bun monorepo (workspaces, Turborepo):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `apps/api` | The API: Elysia on Bun, Better Auth, the realtime WebSocket. Port 4101. |
|
||||||
|
| `apps/web` | The website: Next.js 16 (React server components), Tailwind CSS 4, shadcn/ui. Port 4100. Calls the API through its own `/api` (rewrites), typed end to end with Eden. |
|
||||||
|
| `packages/db` | Drizzle schema and migrations; Postgres in production, PGlite (in-process Postgres) for development and tests. |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Needs Bun (1.4) and Node (for Next.js).
|
||||||
|
|
||||||
|
bun install
|
||||||
|
bun run dev # the API on :4101 (database in apps/api/data) and the website on :4100
|
||||||
|
|
||||||
|
`bun run test` tests the API against PGlite; `bun run check-types`, `bun run build`.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
go run ./cmd/t5de-hub -dev
|
- **Self-hosted**: `AUTH_SECRET=$(openssl rand -base64 32) docker compose up -d` runs Postgres, the API and the
|
||||||
|
website (see `docker-compose.yml` for the public addresses).
|
||||||
|
- **Elsewhere**: the website deploys as is to Vercel and similar (`apps/web`, env `API_URL`); the API to any host
|
||||||
|
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).
|
||||||
|
|
||||||
runs the hub at http://127.0.0.1:8080 with its own Postgres (downloaded once, kept in `t5de-hub-data/`). In
|
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
|
||||||
production: `t5de-hub -db postgres://... -public-url https://hub.t5de.dev -listen :8080 -data /var/lib/t5de-hub`
|
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
|
||||||
(the data directory holds the token signing key).
|
|
||||||
|
|
||||||
To point a T5DE proxy at it: `t5de-proxy signin -hub http://127.0.0.1:8080`.
|
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
`go test ./...` runs everything, the website and API against a real Postgres (embedded, downloaded on first run
|
|
||||||
and cached; `T5DE_PG_CACHE` sets where), or the Postgres at `T5DE_TEST_DB` (as CI does), and a fake IMVU API.
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# The hub's API. Build from the repository root: docker build -f apps/api/Dockerfile .
|
||||||
|
FROM oven/bun:1.4.4-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
# 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
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production PORT=4101
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY packages/db ./packages/db
|
||||||
|
COPY apps/api ./apps/api
|
||||||
|
USER bun
|
||||||
|
EXPOSE 4101
|
||||||
|
CMD ["bun", "apps/api/src/index.ts"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@t5de/api",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/app.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --watch src/index.ts",
|
||||||
|
"start": "bun src/index.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@t5de/db": "workspace:*",
|
||||||
|
"better-auth": "1.7.14",
|
||||||
|
"elysia": "1.4.30",
|
||||||
|
"jose": "6.2.14",
|
||||||
|
"sanitize-html": "2.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.4.4",
|
||||||
|
"@types/sanitize-html": "^2.16.2",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1521
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
|||||||
|
// Accounts, sessions and proxy sign-in, by Better Auth: email and password with usernames, roles and bans (admin),
|
||||||
|
// device sign-in for T5DE's proxy (RFC 8628), bearer tokens for the proxy's requests, and JWTs with published keys
|
||||||
|
// (for catalogs later).
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
|
||||||
|
import { count, schema, type Database } from "@t5de/db";
|
||||||
|
|
||||||
|
export type AuthConfig = {
|
||||||
|
/** The API's own address, e.g. https://hub-api.t5de.dev. */
|
||||||
|
apiURL: string;
|
||||||
|
/** The website's address, e.g. https://hub.t5de.dev: trusted origin, and where /device is. */
|
||||||
|
webURL: string;
|
||||||
|
/** At least 32 random characters; signs sessions. */
|
||||||
|
secret: string;
|
||||||
|
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
|
||||||
|
rateLimit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The proxy's client ID in device sign-in. */
|
||||||
|
export const proxyClientID = "t5de-proxy";
|
||||||
|
|
||||||
|
export function createAuth(db: Database, config: AuthConfig) {
|
||||||
|
return betterAuth({
|
||||||
|
baseURL: config.apiURL,
|
||||||
|
basePath: "/api/auth",
|
||||||
|
secret: config.secret,
|
||||||
|
trustedOrigins: [config.webURL],
|
||||||
|
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||||
|
emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 256 },
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
linkCap: { type: "number", defaultValue: 10, input: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Proxies stay signed in for months; a session is extended when it's used.
|
||||||
|
session: { expiresIn: 60 * 60 * 24 * 60, updateAge: 60 * 60 * 24 },
|
||||||
|
rateLimit: { enabled: config.rateLimit !== false, window: 60, max: 100 },
|
||||||
|
advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] } },
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
// The first account runs the hub.
|
||||||
|
before: async (u) => {
|
||||||
|
const [row] = await db.select({ n: count() }).from(schema.user);
|
||||||
|
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
username({
|
||||||
|
minUsernameLength: 3,
|
||||||
|
maxUsernameLength: 32,
|
||||||
|
usernameValidator: (name) => /^[A-Za-z0-9_]+$/.test(name),
|
||||||
|
}),
|
||||||
|
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
||||||
|
deviceAuthorization({
|
||||||
|
verificationUri: `${config.webURL}/device`,
|
||||||
|
expiresIn: "10m",
|
||||||
|
interval: "3s",
|
||||||
|
validateClient: (clientId) => clientId === proxyClientID,
|
||||||
|
}),
|
||||||
|
bearer(),
|
||||||
|
jwt({ jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Auth = ReturnType<typeof createAuth>;
|
||||||
@@ -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() } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
|
||||||
|
// 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 = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
tagline: string;
|
||||||
|
ageVerified: 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 {
|
||||||
|
constructor() {
|
||||||
|
super("no such IMVU account");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImvuUnavailable extends Error {}
|
||||||
|
|
||||||
|
// Calls are spaced out so the hub never floods IMVU.
|
||||||
|
const minInterval = 200;
|
||||||
|
|
||||||
|
export class ImvuClient {
|
||||||
|
private next = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly base = "https://api.imvu.com",
|
||||||
|
private readonly fetchFn: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
byUsername(username: string): Promise<ImvuUser> {
|
||||||
|
const name = username.trim();
|
||||||
|
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
|
||||||
|
return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse);
|
||||||
|
}
|
||||||
|
|
||||||
|
byId(id: number): Promise<ImvuUser> {
|
||||||
|
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() {
|
||||||
|
const now = Date.now();
|
||||||
|
const at = Math.max(now, this.next);
|
||||||
|
this.next = at + minInterval;
|
||||||
|
if (at > now) await Bun.sleep(at - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await this.fetchFn(this.base + path, {
|
||||||
|
headers: { "User-Agent": "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)" },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new ImvuUnavailable(String(e));
|
||||||
|
}
|
||||||
|
if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
|
||||||
|
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||||
|
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.
|
||||||
|
export function parse(answer: unknown): ImvuUser {
|
||||||
|
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||||
|
const users = Object.entries(den).filter(([key]) => key.includes("/user/user-"));
|
||||||
|
if (users.length !== 1) throw new NoSuchImvuUser();
|
||||||
|
const data = users[0]![1].data ?? {};
|
||||||
|
const id = Number(data.legacy_cid);
|
||||||
|
const username = typeof data.username === "string" ? data.username : "";
|
||||||
|
if (!Number.isSafeInteger(id) || id <= 0 || !username) throw new NoSuchImvuUser();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
tagline: typeof data.tagline === "string" ? data.tagline : "",
|
||||||
|
ageVerified: data.is_ageverified === true,
|
||||||
|
accessPass: data.is_ap === true,
|
||||||
|
picture: imvuImage(data.thumbnail_url),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Runs the hub's API. Configuration (environment):
|
||||||
|
// DATABASE_URL postgres://... (default: PGlite files in ./data/db, for development)
|
||||||
|
// AUTH_SECRET at least 32 random characters (required unless NODE_ENV is development)
|
||||||
|
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
|
||||||
|
// WEB_URL the website's address (default http://localhost:4100)
|
||||||
|
// PORT default 4101
|
||||||
|
import { openDatabase } from "@t5de/db";
|
||||||
|
import { createApp } from "./app";
|
||||||
|
import { createAuth } from "./auth";
|
||||||
|
import { ImvuClient } from "./imvu";
|
||||||
|
|
||||||
|
const env = process.env;
|
||||||
|
const dev = env.NODE_ENV !== "production";
|
||||||
|
const port = Number(env.PORT ?? 4101);
|
||||||
|
const secret = env.AUTH_SECRET ?? (dev ? "development-secret-not-for-production-use" : "");
|
||||||
|
if (secret.length < 32) {
|
||||||
|
console.error("AUTH_SECRET must be at least 32 characters");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const apiURL = env.API_URL ?? `http://localhost:${port}`;
|
||||||
|
const webURL = env.WEB_URL ?? "http://localhost:4100";
|
||||||
|
|
||||||
|
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||||
|
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||||
|
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||||
|
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})`);
|
||||||
@@ -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" }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
|
||||||
|
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
|
||||||
|
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
|
||||||
|
//
|
||||||
|
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
||||||
|
// a bearer token):
|
||||||
|
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
||||||
|
// 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": "..."}
|
||||||
|
//
|
||||||
|
// 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; adult: boolean };
|
||||||
|
|
||||||
|
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
||||||
|
|
||||||
|
/** What realtime needs to know of a hosted room to let someone in. */
|
||||||
|
export type HostedRoomInfo = {
|
||||||
|
closed: boolean;
|
||||||
|
adult: boolean;
|
||||||
|
maxUsers: number;
|
||||||
|
privacy: "public" | "private";
|
||||||
|
ownerImvuId: number;
|
||||||
|
moderators: number[];
|
||||||
|
name: string;
|
||||||
|
ownerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
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 readonly deps: Required<RealtimeDeps>;
|
||||||
|
|
||||||
|
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, outfits or rooms; returns a function that stops it. */
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private changed(chats: Iterable<string> = []) {
|
||||||
|
for (const chat of new Set(chats)) this.sync(chat);
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
|
||||||
|
online(): Online[] {
|
||||||
|
const byId = new Map<number, Online>();
|
||||||
|
for (const s of this.sessions.values()) {
|
||||||
|
if (!s.imvuId) continue;
|
||||||
|
const prev = byId.get(s.imvuId);
|
||||||
|
if (prev && prev.since > s.since) continue;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
connections(): number {
|
||||||
|
return this.sessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
|
||||||
|
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) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (s) {
|
||||||
|
this.leaveAllHosted(id, s, false);
|
||||||
|
this.unwatch(id, s);
|
||||||
|
}
|
||||||
|
this.sessions.delete(id);
|
||||||
|
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). */
|
||||||
|
disconnect(userId: string) {
|
||||||
|
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. */
|
||||||
|
async message(id: string, raw: unknown): Promise<object | undefined> {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) return;
|
||||||
|
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.type === "session") {
|
||||||
|
const imvuId = Number(m.imvu_id);
|
||||||
|
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||||
|
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
||||||
|
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
|
||||||
|
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
|
||||||
|
if (owner !== s.userId) {
|
||||||
|
// Not this person's account: no presence, no outfit for it.
|
||||||
|
s.imvuId = 0;
|
||||||
|
s.adult = false;
|
||||||
|
s.outfit = [];
|
||||||
|
this.changed(s.chats.keys());
|
||||||
|
return { type: "error", message: "not_linked" };
|
||||||
|
}
|
||||||
|
s.imvuId = imvuId;
|
||||||
|
s.since = new Date();
|
||||||
|
s.outfit = [];
|
||||||
|
s.adult = await this.deps.adult(s.userId, imvuId);
|
||||||
|
this.changed(s.chats.keys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type === "outfit") {
|
||||||
|
const items = 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.id && it.id.length <= 128 && it.name.length <= 128)
|
||||||
|
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
||||||
|
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 { type: "error", message: "unknown message type" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJSON(s: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// The whole API against an in-process Postgres (PGlite) and a fake IMVU.
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { openDatabase, type Database } from "@t5de/db";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
import { createAuth, proxyClientID } from "../src/auth";
|
||||||
|
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||||
|
|
||||||
|
const webURL = "http://web.test";
|
||||||
|
const accounts = new Map<number, ImvuUser>();
|
||||||
|
|
||||||
|
// IMVU's public user API, answering from accounts.
|
||||||
|
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const found = [...accounts.values()].find(
|
||||||
|
(u) =>
|
||||||
|
url.pathname === `/user/user-${u.id}` ||
|
||||||
|
(url.pathname === "/user" && url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase()),
|
||||||
|
);
|
||||||
|
const denormalized = found
|
||||||
|
? {
|
||||||
|
[`https://api.imvu.com/user/user-${found.id}`]: {
|
||||||
|
data: {
|
||||||
|
legacy_cid: found.id,
|
||||||
|
username: found.username,
|
||||||
|
tagline: found.tagline,
|
||||||
|
is_ageverified: found.ageVerified,
|
||||||
|
is_ap: found.accessPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return Response.json({ status: "success", denormalized });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
let hub: ReturnType<typeof createApp>;
|
||||||
|
let server: { port: number; stop: () => void } | undefined;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
||||||
|
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||||
|
server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||||
|
base = `http://127.0.0.1:${server.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
server?.stop();
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A person using the website: keeps the session cookie. */
|
||||||
|
class Browser {
|
||||||
|
cookie = "";
|
||||||
|
async call(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" } : {}),
|
||||||
|
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const set = res.headers.getSetCookie();
|
||||||
|
if (set.length) this.cookie = set.map((c) => c.split(";")[0]).join("; ");
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
json = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, json };
|
||||||
|
}
|
||||||
|
async signup(username: string) {
|
||||||
|
const r = await this.call("POST", "/api/auth/sign-up/email", {
|
||||||
|
email: `${username.toLowerCase()}@example.test`,
|
||||||
|
password: "a long password",
|
||||||
|
name: username,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links an IMVU account through the API, putting the code in its fake tagline. */
|
||||||
|
async function link(b: Browser, u: Omit<ImvuUser, "tagline">) {
|
||||||
|
accounts.set(u.id, { ...u, tagline: "" });
|
||||||
|
const started = await b.call("POST", "/api/v1/account/links", { username: u.username });
|
||||||
|
expect(started.status).toBe(201);
|
||||||
|
accounts.set(u.id, { ...u, tagline: `hi ${started.json.challenge.code.toLowerCase()}` });
|
||||||
|
return b.call("POST", `/api/v1/account/links/${u.id}/check`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("accounts", () => {
|
||||||
|
test("the first account is the admin; usernames sign in", async () => {
|
||||||
|
const alice = new Browser();
|
||||||
|
await alice.signup("alice");
|
||||||
|
expect((await alice.call("GET", "/api/v1/me")).json.person.role).toBe("admin");
|
||||||
|
const bob = new Browser();
|
||||||
|
await bob.signup("bob");
|
||||||
|
expect((await bob.call("GET", "/api/v1/me")).json.person.role).toBe("user");
|
||||||
|
expect((await bob.call("GET", "/api/v1/console")).status).toBe(403);
|
||||||
|
|
||||||
|
const again = new Browser();
|
||||||
|
const wrong = await again.call("POST", "/api/auth/sign-in/username", { username: "bob", password: "nope nope nope" });
|
||||||
|
expect(wrong.status).toBe(401);
|
||||||
|
const right = await again.call("POST", "/api/auth/sign-in/username", { username: "BOB", password: "a long password" });
|
||||||
|
expect(right.status).toBe(200);
|
||||||
|
expect((await again.call("GET", "/api/v1/me")).json.person.username).toBe("bob");
|
||||||
|
|
||||||
|
const taken = await new Browser().call("POST", "/api/auth/sign-up/email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
password: "a long password",
|
||||||
|
name: "Alice",
|
||||||
|
username: "ALICE",
|
||||||
|
});
|
||||||
|
expect(taken.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect((await new Browser().call("GET", "/api/v1/me")).status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linking IMVU accounts", () => {
|
||||||
|
test("by tagline; the most recent proof wins; link caps", async () => {
|
||||||
|
const carol = new Browser();
|
||||||
|
await carol.signup("carol");
|
||||||
|
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" });
|
||||||
|
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");
|
||||||
|
expect(missing.status).toBe(409);
|
||||||
|
expect(missing.json.error).toBe("code_missing");
|
||||||
|
|
||||||
|
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null });
|
||||||
|
expect(linked.status).toBe(200);
|
||||||
|
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(
|
||||||
|
"no_such_imvu_account",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dave proves the same account is his: it moves, with a new pairwise ID.
|
||||||
|
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
|
||||||
|
const dave = new Browser();
|
||||||
|
await dave.signup("dave");
|
||||||
|
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null });
|
||||||
|
expect(moved.json.moved).toBe(true);
|
||||||
|
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
|
||||||
|
expect(after.userId).not.toBe(before.userId);
|
||||||
|
expect(after.pairwiseId).not.toBe(before.pairwiseId);
|
||||||
|
expect((await carol.call("GET", "/api/v1/account")).json.links).toHaveLength(0);
|
||||||
|
|
||||||
|
// A moderator lowers Dave's link cap to 1: a second account is refused.
|
||||||
|
const admin = new Browser();
|
||||||
|
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;
|
||||||
|
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, picture: null });
|
||||||
|
expect(over.status).toBe(403);
|
||||||
|
expect(over.json.error).toBe("link_cap");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("T5DE's proxy", () => {
|
||||||
|
test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
|
||||||
|
const erin = new Browser();
|
||||||
|
await erin.signup("erin");
|
||||||
|
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.
|
||||||
|
const codeRes = await fetch(`${base}/api/auth/device/code`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_id: proxyClientID }),
|
||||||
|
});
|
||||||
|
const device = await codeRes.json();
|
||||||
|
expect(device.verification_uri).toBe(`${webURL}/device`);
|
||||||
|
const poll = async () =>
|
||||||
|
(
|
||||||
|
await fetch(`${base}/api/auth/device/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "User-Agent": "t5de-proxy/test" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: device.device_code,
|
||||||
|
client_id: proxyClientID,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
expect((await poll()).error).toBe("authorization_pending");
|
||||||
|
const userCode = device.user_code as string;
|
||||||
|
const approved = await erin.call("POST", "/api/v1/device/approve", {
|
||||||
|
code: userCode.toLowerCase().slice(0, 4) + "-" + userCode.toLowerCase().slice(4),
|
||||||
|
});
|
||||||
|
expect(approved.status).toBe(204);
|
||||||
|
await Bun.sleep(3100); // the proxy keeps to the polling interval
|
||||||
|
const tokens = await poll();
|
||||||
|
expect(tokens.access_token).toBeString();
|
||||||
|
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||||
|
const me = await fetch(`${base}/api/v1/me`, { headers: bearer });
|
||||||
|
expect((await me.json()).person.username).toBe("erin");
|
||||||
|
|
||||||
|
// The live connection: another person's account is refused; Erin's shows online with her outfit, to moderators,
|
||||||
|
// and to everyone once she opts in.
|
||||||
|
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: bearer } as any);
|
||||||
|
const messages: any[] = [];
|
||||||
|
ws.onmessage = (e) => messages.push(JSON.parse(String(e.data)));
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
|
||||||
|
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: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] }));
|
||||||
|
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1));
|
||||||
|
|
||||||
|
const visitor = new Browser();
|
||||||
|
expect((await visitor.call("GET", "/api/v1/online")).json.online).toHaveLength(0);
|
||||||
|
const admin = new Browser();
|
||||||
|
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
|
||||||
|
const consoleView = (await admin.call("GET", "/api/v1/console")).json;
|
||||||
|
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
|
||||||
|
expect(consoleView.counts.computers).toBe(1);
|
||||||
|
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
||||||
|
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
||||||
|
expect(online).toEqual([
|
||||||
|
{ 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.
|
||||||
|
const erinId = (await erin.call("GET", "/api/v1/me")).json.person.id;
|
||||||
|
const closed = new Promise((r) => (ws.onclose = r));
|
||||||
|
expect((await admin.call("POST", `/api/v1/console/people/${erinId}/ban`, { reason: "testing" })).status).toBe(200);
|
||||||
|
await closed;
|
||||||
|
expect((await fetch(`${base}/api/v1/me`, { headers: bearer })).status).toBe(401);
|
||||||
|
expect((await admin.call("GET", "/api/v1/console")).json.audit.map((e: any) => e.action)).toContain("person.ban");
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(ok: () => boolean) {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
if (ok()) return;
|
||||||
|
await Bun.sleep(10);
|
||||||
|
}
|
||||||
|
throw new Error("timed out");
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true,
|
||||||
|
"noEmit": true, "skipLibCheck": true, "types": ["bun"], "noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/.next/
|
||||||
|
/node_modules/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# The hub's website as a standalone Next.js server. Build from the repository root (the app uses the workspace's
|
||||||
|
# packages for the API's types):
|
||||||
|
#
|
||||||
|
# docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
|
||||||
|
# docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
|
||||||
|
#
|
||||||
|
# API_URL is needed at build time too: Next fixes the /api rewrite when it builds.
|
||||||
|
|
||||||
|
FROM oven/bun:1.4.4 AS bun
|
||||||
|
|
||||||
|
FROM node:26-slim AS build
|
||||||
|
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
WORKDIR /repo
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Dependencies first, so they're cached while only source changes.
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY packages/db packages/db
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY apps/web apps/web
|
||||||
|
ARG API_URL=http://api:4101
|
||||||
|
ENV API_URL=$API_URL
|
||||||
|
RUN cd apps/web && bun run build
|
||||||
|
|
||||||
|
FROM node:26-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4100 HOSTNAME=0.0.0.0
|
||||||
|
ARG API_URL=http://api:4101
|
||||||
|
ENV API_URL=$API_URL
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
USER node
|
||||||
|
EXPOSE 4100
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/data
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
dist
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# T5DE Hub website
|
||||||
|
|
||||||
|
The hub's website: accounts, linked IMVU accounts, who's online, signing T5DE in, and the moderation console.
|
||||||
|
Next.js (App Router), Tailwind CSS and shadcn/ui. Pages are rendered on the server, which calls the hub's API
|
||||||
|
(`apps/api`) with the visitor's cookie; forms use server actions. The browser only talks to this app: `/api/*` is
|
||||||
|
rewritten to the API, so the API's session cookies belong to the website's origin.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
From the repository root, `bun install`, then start the API (`cd apps/api && bun src/index.ts`) and the website:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/web
|
||||||
|
bun run dev # http://localhost:4100
|
||||||
|
bun run check-types
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The first account created is the hub's admin.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | What |
|
||||||
|
| --------- | ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `API_URL` | `http://localhost:4101` | The API's address as this app reaches it. Needed at build time and at run time (below). |
|
||||||
|
| `PORT` | `4100` | The standalone server's port (`next start` and `bun run dev` use 4100). |
|
||||||
|
|
||||||
|
The API's `WEB_URL` must be this website's public address (it's where Better Auth trusts requests from, and where
|
||||||
|
T5DE's sign-in code page is).
|
||||||
|
|
||||||
|
`API_URL` is read in two places: by server code on every request, and by `next.config.ts`'s `/api` rewrite, which
|
||||||
|
Next fixes when it builds. So build with the same `API_URL` the app will run with.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Container
|
||||||
|
|
||||||
|
The `Dockerfile` builds a standalone server (`output: "standalone"`). Build it from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
|
||||||
|
docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Next's own gzip is off (it held back the API's live event stream), so put a reverse proxy that compresses in front,
|
||||||
|
and don't let it buffer `text/event-stream` responses (`/api/v1/online/events`).
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
Import the repository with the project's root directory set to `apps/web` (Vercel installs the Bun workspace from the
|
||||||
|
root). Set `API_URL` to the API's public address for both builds and runtime. Nothing else is needed: there's no
|
||||||
|
custom server.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const done = () => revalidatePath("/account");
|
||||||
|
|
||||||
|
/** Starts linking an IMVU account: the API makes a code to put in its tagline. */
|
||||||
|
export async function linkAccount(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account.links.post({ username: field(form, "username") }));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending link: check the tagline for the code, or cancel. */
|
||||||
|
export async function challengeAction(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const imvu = field(form, "imvu");
|
||||||
|
return attempt(async () => {
|
||||||
|
if (field(form, "intent") === "cancel") {
|
||||||
|
unwrap(await api.account.challenges({ imvu }).delete());
|
||||||
|
} else {
|
||||||
|
unwrap(await api.account.links({ imvu }).check.post());
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A linked account: check its age status with IMVU again, or unlink it. */
|
||||||
|
export async function linkAction(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const imvu = field(form, "imvu");
|
||||||
|
return attempt(async () => {
|
||||||
|
if (field(form, "intent") === "unlink") {
|
||||||
|
unwrap(await api.account.links({ imvu }).delete());
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unwrap(await api.account.links({ imvu }).recheck.post());
|
||||||
|
done();
|
||||||
|
return "Checked with IMVU just now.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(
|
||||||
|
imvu: number,
|
||||||
|
settings: { showBadge?: boolean; showPresence?: boolean; listRooms?: boolean },
|
||||||
|
): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutComputers(_: ActionState, _form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account["signout-computers"].post());
|
||||||
|
done();
|
||||||
|
return "Signed out everywhere.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Challenge } from "@/lib/api";
|
||||||
|
import { challengeAction } from "./actions";
|
||||||
|
|
||||||
|
/** A link in progress: the code to put in the tagline, then Check. */
|
||||||
|
export function ChallengeCard({ challenge: c }: { challenge: Challenge }) {
|
||||||
|
return (
|
||||||
|
<Card id={`link-${c.imvuId}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link {c.username}</CardTitle>
|
||||||
|
<CardDescription>To prove {c.username} is yours:</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={challengeAction} fields={{ imvu: c.imvuId }} className="space-y-6">
|
||||||
|
<ol className="space-y-5 text-sm">
|
||||||
|
<Step n={1}>
|
||||||
|
<p>
|
||||||
|
Put this code anywhere in {c.username}'s IMVU tagline (on your avatar card in the client, or your
|
||||||
|
profile):
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<code className="rounded-lg border bg-muted/50 px-4 py-2.5 font-mono text-xl font-semibold tracking-wider select-all sm:text-2xl">
|
||||||
|
{c.code}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={c.code} />
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
<p>Press Check. You can take the code out again once it's linked.</p>
|
||||||
|
</Step>
|
||||||
|
</ol>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SubmitButton name="intent" value="check" pendingLabel="Checking…">
|
||||||
|
Check
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton variant="outline" name="intent" value="cancel" pendingLabel="Cancelling…">
|
||||||
|
Cancel
|
||||||
|
</SubmitButton>
|
||||||
|
<span className="text-sm text-muted-foreground">The code works for a day.</span>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ n, children }: { n: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="grid size-6 shrink-0 place-items-center rounded-full border text-xs font-medium text-muted-foreground">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { AgeTag, Initial, Outfit, Tag } from "@/components/status";
|
||||||
|
import { TimeAgo } from "@/components/time";
|
||||||
|
import type { Link } from "@/lib/api";
|
||||||
|
import { linkAction } from "./actions";
|
||||||
|
import { LinkSettings } from "./link-settings";
|
||||||
|
|
||||||
|
/** One linked IMVU account. */
|
||||||
|
export function LinkCard({ link }: { link: Link }) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-4 py-6 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={link.username} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
|
||||||
|
<div className="min-w-0 flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">{link.username}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{link.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
Online in T5DE
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<AgeTag link={link} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
checked <TimeAgo iso={link.checkedAt} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Outfit items={link.outfit} />
|
||||||
|
<LinkSettings
|
||||||
|
imvuId={link.imvuId}
|
||||||
|
showBadge={link.showBadge}
|
||||||
|
showPresence={link.showPresence}
|
||||||
|
listRooms={link.listRooms}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||||
|
<div className="flex gap-2 sm:justify-end">
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="recheck"
|
||||||
|
pendingLabel="Checking…"
|
||||||
|
title="Check age verification with IMVU again"
|
||||||
|
>
|
||||||
|
Check again
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="unlink"
|
||||||
|
pendingLabel="Unlinking…"
|
||||||
|
confirm={`Unlink ${link.username}? To link it again, you'll need a new code in its tagline.`}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
// A linked account's two settings, saved as soon as they're switched.
|
||||||
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
|
import { FormMessage } from "@/components/forms";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { ActionState } from "@/lib/actions";
|
||||||
|
import { saveSettings } from "./actions";
|
||||||
|
|
||||||
|
type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean };
|
||||||
|
|
||||||
|
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
||||||
|
const [settings, setOptimistic] = useOptimistic(saved);
|
||||||
|
const [state, setState] = useState<ActionState>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const change = (key: keyof Settings) => (value: boolean) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimistic({ ...settings, [key]: value });
|
||||||
|
setState(await saveSettings(imvuId, { [key]: value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showBadge} onCheckedChange={change("showBadge")} disabled={pending} />
|
||||||
|
Show T5DE badge
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||||
|
Show when I'm online in T5DE
|
||||||
|
</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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api, getMe, isModerator, unwrap } from "@/lib/api";
|
||||||
|
import { linkAccount, signOutComputers } from "./actions";
|
||||||
|
import { ChallengeCard } from "./challenge-card";
|
||||||
|
import { LinkCard } from "./link-card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Account" };
|
||||||
|
|
||||||
|
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect("/signin?next=/account");
|
||||||
|
const account = unwrap(await api.account.get());
|
||||||
|
const { person, links, challenges, computers } = account;
|
||||||
|
const welcome = (await searchParams).welcome !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Your account">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
Signed in as {person.username}
|
||||||
|
{isModerator(person) && <Tag tone="strong">{person.role}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{welcome && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Welcome to T5DE, {person.username}.</AlertTitle>
|
||||||
|
<AlertDescription>Link an IMVU account below to get started.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{links.length > 0 ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{links.map((link) => (
|
||||||
|
<LinkCard key={link.imvuId} link={link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No IMVU accounts linked yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{challenges.map((c) => (
|
||||||
|
<ChallengeCard key={c.imvuId} challenge={c} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link an IMVU account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{account.canLink ? (
|
||||||
|
<ActionForm action={linkAccount}>
|
||||||
|
<div className="flex max-w-md gap-2">
|
||||||
|
<Input name="username" placeholder="IMVU username" required maxLength={64} aria-label="IMVU username" />
|
||||||
|
<SubmitButton pendingLabel="Looking…">Next</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">
|
||||||
|
You've linked as many IMVU accounts as you can ({person.linkCap}). Ask a moderator if you need more.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
|
||||||
|
can see adult community products; each account is checked on its own.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>T5DE on your computers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{computers > 0
|
||||||
|
? `Signed in on ${computers} computer${computers === 1 ? "" : "s"}.`
|
||||||
|
: "Not signed in on any computer yet."}{" "}
|
||||||
|
To sign T5DE in, it shows a code to enter at{" "}
|
||||||
|
<Link href="/device" className="text-foreground underline underline-offset-4">
|
||||||
|
/device
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
{computers > 0 && (
|
||||||
|
<ActionForm action={signOutComputers}>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Signing out…"
|
||||||
|
confirm="Sign T5DE out on all your computers?"
|
||||||
|
>
|
||||||
|
Sign out everywhere
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const roles = ["user", "moderator", "admin"] as const;
|
||||||
|
|
||||||
|
/** Runs a change to a person from the console, then shows their page again. */
|
||||||
|
async function onPerson(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
|
||||||
|
const id = field(form, "id");
|
||||||
|
return attempt(async () => {
|
||||||
|
const notice = await change(id);
|
||||||
|
revalidatePath(`/console/people/${id}`);
|
||||||
|
return notice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLinkCap(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
const linkCap = Number(field(form, "linkCap"));
|
||||||
|
const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap }));
|
||||||
|
return `Link cap set to ${person.linkCap}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ban(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unban(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
unwrap(await api.console.people({ id }).unban.post());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
const role = roles.find((r) => r === field(form, "role")) ?? "user";
|
||||||
|
const { person } = unwrap(await api.console.people({ id }).role.post({ 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { NavLink } from "@/components/header-client";
|
||||||
|
import { getMe, isModerator, type Person } from "@/lib/api";
|
||||||
|
|
||||||
|
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
|
||||||
|
export async function requireModerator(path: string): Promise<Person> {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||||
|
if (!isModerator(me)) notFound();
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsoleNav() {
|
||||||
|
return (
|
||||||
|
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||||
|
<NavLink href="/console" exact>
|
||||||
|
Overview
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/console/people">People</NavLink>
|
||||||
|
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OnlineList } from "@/components/online-list";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, unwrap, type AuditEntry } from "@/lib/api";
|
||||||
|
import { ConsoleNav, requireModerator } from "./console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Console" };
|
||||||
|
|
||||||
|
export default async function Console() {
|
||||||
|
await requireModerator("/console");
|
||||||
|
const data = unwrap(await api.console.get());
|
||||||
|
const stats = [
|
||||||
|
{ label: "People", value: data.counts.people, note: "accounts" },
|
||||||
|
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
|
||||||
|
{ label: "Computers", value: data.counts.computers, note: "signed in" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console">Moderation.</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Card key={s.label}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{s.label}</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Online now</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={data.online} moderator />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>The latest 50 changes.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AuditLog entries={data.audit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||||
|
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Who</TableHead>
|
||||||
|
<TableHead>What</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Detail</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={e.at} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||||
|
<TableCell>{e.target}</TableCell>
|
||||||
|
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||||
|
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||||
|
.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { AgeTag, PageHeader, Tag } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, ApiError, unwrap } from "@/lib/api";
|
||||||
|
import { ban, setLinkCap, setRole, unban } from "../../actions";
|
||||||
|
import { ConsoleNav, requireModerator } from "../../console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person" };
|
||||||
|
|
||||||
|
export default async function PersonPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const me = await requireModerator(`/console/people/${id}`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = unwrap(await api.console.people({ id }).get());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) notFound();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const { person, links, computers } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={person.username}>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="capitalize">{person.role}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>
|
||||||
|
joined <DateTime iso={person.createdAt} dateOnly />
|
||||||
|
</span>
|
||||||
|
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}. Only moderators see which IMVU accounts belong to one person.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">None.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>IMVU account</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Linked</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead>Shows</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{links.map((l) => (
|
||||||
|
<TableRow key={l.imvuId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{l.username}
|
||||||
|
{l.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{l.imvuId}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={l.linkedAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AgeTag link={l} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{[l.showBadge && "badge", l.showPresence && "presence"].filter(Boolean).join(", ") || "nothing"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Signed in on {computers} computer{computers === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link cap</CardTitle>
|
||||||
|
<CardDescription>How many IMVU accounts they can link (normally 10).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setLinkCap} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="linkCap"
|
||||||
|
defaultValue={person.linkCap}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
required
|
||||||
|
aria-label="Link cap"
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ban</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{person.banned
|
||||||
|
? `Banned: ${person.banReason}`
|
||||||
|
: "Covers all their IMVU accounts, and signs them out everywhere."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.banned ? (
|
||||||
|
<ActionForm action={unban} fields={{ id: person.id }}>
|
||||||
|
<SubmitButton variant="outline" pendingLabel="Unbanning…">
|
||||||
|
Unban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={ban} fields={{ id: person.id }}>
|
||||||
|
<Input
|
||||||
|
name="reason"
|
||||||
|
placeholder="Reason (they see it)"
|
||||||
|
required
|
||||||
|
maxLength={500}
|
||||||
|
aria-label="Reason"
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
className="bg-red-600 text-white hover:bg-red-600/90 dark:bg-red-600 dark:hover:bg-red-600/90"
|
||||||
|
pendingLabel="Banning…"
|
||||||
|
confirm={`Ban ${person.username}?`}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{me.role === "admin" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role</CardTitle>
|
||||||
|
<CardDescription>Moderators use the console; admins also set roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setRole} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NativeSelect name="role" defaultValue={person.role} aria-label="Role">
|
||||||
|
<NativeSelectOption value="user">User</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="moderator">Moderator</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="admin">Admin</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "People" };
|
||||||
|
|
||||||
|
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||||
|
const raw = (await searchParams).q;
|
||||||
|
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
|
||||||
|
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
|
||||||
|
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="People" />
|
||||||
|
<ConsoleNav />
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form action="/console/people" className="flex max-w-md gap-2">
|
||||||
|
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nobody found.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Link cap</TableHead>
|
||||||
|
<TableHead>Joined</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{people.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/console/people/${p.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||||
|
{p.username}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{p.role}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{p.linkCap}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={p.createdAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// Where creating a product used to be.
|
||||||
|
export default function Create() {
|
||||||
|
redirect("/products/new");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use server";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
/** Approves the sign-in code T5DE shows, signing that computer in as this person. */
|
||||||
|
export async function approveDevice(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.device.approve.post({ code: field(form, "code") }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { FormMessage, SubmitButton } from "@/components/forms";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { approveDevice } from "./actions";
|
||||||
|
|
||||||
|
export function DeviceForm({ code, username }: { code: string; username: string }) {
|
||||||
|
const [state, action] = useActionState(approveDevice, null);
|
||||||
|
|
||||||
|
if (state && !state.error)
|
||||||
|
return <NarrowCard title="T5DE is signed in" description="You can close this page and go back to T5DE." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sign T5DE in" description={`Enter the code T5DE shows, to sign it in as ${username}.`}>
|
||||||
|
<form action={action}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="code">Code</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
defaultValue={code}
|
||||||
|
placeholder="XXXX-XXXX"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-11 text-center font-mono text-lg tracking-widest uppercase"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<FormMessage state={state} />
|
||||||
|
<Field>
|
||||||
|
<SubmitButton pendingLabel="Signing in…">Sign in</SubmitButton>
|
||||||
|
<FieldDescription>Only enter a code you got from T5DE yourself.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { DeviceForm } from "./device-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Sign T5DE in" };
|
||||||
|
|
||||||
|
type Search = { user_code?: string | string[]; code?: string | string[] };
|
||||||
|
|
||||||
|
export default async function Device({ searchParams }: { searchParams: Promise<Search> }) {
|
||||||
|
const search = await searchParams;
|
||||||
|
// Better Auth's verification link carries user_code; ?code= is shorter to type.
|
||||||
|
const code = [search.user_code, search.code].find((c) => typeof c === "string") ?? "";
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) {
|
||||||
|
const here = code ? `/device?code=${encodeURIComponent(code)}` : "/device";
|
||||||
|
redirect(`/signin?next=${encodeURIComponent(here)}`);
|
||||||
|
}
|
||||||
|
return <DeviceForm code={code} username={me.username} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sorry" description="Something went wrong on our side. Try again in a moment.">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={reset}>Try again</Button>
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (@media (prefers-color-scheme: dark));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@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 |
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Header } from "@/components/header";
|
||||||
|
import { StagingBanner } from "@/components/staging-banner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const sans = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { template: "%s · T5DE Hub", default: "T5DE Hub" },
|
||||||
|
description: "One account for all your IMVU avatars.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
|
||||||
|
<body className="flex min-h-dvh flex-col">
|
||||||
|
<StagingBanner />
|
||||||
|
<Header />
|
||||||
|
<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">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
|
||||||
|
T5DE is unofficial: not made by or affiliated with IMVU.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Not found" description="There's nothing at this address.">
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OnlineList } from "@/components/online-list";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { api, getMe, isModerator, unwrap } from "@/lib/api";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Online" };
|
||||||
|
|
||||||
|
export default async function Online() {
|
||||||
|
const [me, { online }] = await Promise.all([getMe(), api.online.get().then(unwrap)]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Online in T5DE">IMVU accounts in a T5DE client right now, that chose to show it.</PageHeader>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={online} moderator={isModerator(me)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { api, getMe, unwrap } from "@/lib/api";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const [me, home] = await Promise.all([getMe(), api.home.get().then(unwrap)]);
|
||||||
|
const links = "links" in home ? home.links : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-14">
|
||||||
|
<section className="max-w-2xl space-y-5 pt-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">T5DE Hub</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||||
|
One account for all your IMVU avatars.
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-pretty text-muted-foreground">
|
||||||
|
Link your IMVU accounts, see who's online in T5DE, and soon, community products.
|
||||||
|
</p>
|
||||||
|
{!me && (
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<Link href="/signup" className={buttonVariants({ size: "lg", className: "px-4" })}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
<Link href="/signin" className={buttonVariants({ size: "lg", variant: "outline", className: "px-4" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Online in T5DE</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{home.onlineCount}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
people showing that they're online.{" "}
|
||||||
|
<Link href="/online" className="text-foreground underline underline-offset-4">
|
||||||
|
See who
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{links !== undefined ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Your IMVU accounts</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{links}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
linked.{" "}
|
||||||
|
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>One account, all your avatars</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private: nobody else
|
||||||
|
can see they're yours.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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's shown as the room'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { safeNext } from "@/lib/paths";
|
||||||
|
import { SignInForm } from "./signin-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Sign in" };
|
||||||
|
|
||||||
|
export default async function SignIn({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) {
|
||||||
|
const next = safeNext((await searchParams).next);
|
||||||
|
if (await getMe()) redirect(next);
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sign in" description="to your T5DE account.">
|
||||||
|
<SignInForm next={next} />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
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 { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
/** Signs in with Better Auth, then goes to next (already checked to be a path on this site). */
|
||||||
|
export function SignInForm({ next }: { next: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function submit(form: FormData) {
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
const { error } = await authClient.signIn.username({
|
||||||
|
username: String(form.get("username")),
|
||||||
|
password: String(form.get("password")),
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setError(error.message ?? "That didn't work. Try again.");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input id="username" name="username" autoComplete="username" required autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
No account? <Link href="/signup">Create one</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { SignUpForm } from "./signup-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Create account" };
|
||||||
|
|
||||||
|
export default async function SignUp() {
|
||||||
|
if (await getMe()) redirect("/account");
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
|
||||||
|
<SignUpForm />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
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 { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export function SignUpForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function submit(form: FormData) {
|
||||||
|
const username = String(form.get("username"));
|
||||||
|
const password = String(form.get("password"));
|
||||||
|
if (password !== form.get("password2")) {
|
||||||
|
setError("The passwords don't match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
const { error } = await authClient.signUp.email({
|
||||||
|
username,
|
||||||
|
name: username,
|
||||||
|
email: String(form.get("email")),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setError(error.message ?? "That didn't work. Try again.");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/account?welcome=1");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
pattern="[A-Za-z0-9_]{3,32}"
|
||||||
|
title="3 to 32 letters, digits or underscores"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password2">Password again</FieldLabel>
|
||||||
|
<Input id="password2" name="password2" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
<FieldDescription>Not your IMVU password: use a new one.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Creating account…" : "Create account"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
Have one? <Link href="/signin">Sign in</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
// Forms that run a server action and show its answer, with buttons that show when they're working.
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ActionState } from "@/lib/actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Action = (state: ActionState, form: FormData) => Promise<ActionState>;
|
||||||
|
|
||||||
|
export function ActionForm({
|
||||||
|
action,
|
||||||
|
fields = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
action: Action;
|
||||||
|
/** Hidden fields sent with the form. */
|
||||||
|
fields?: Record<string, string | number>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [state, formAction] = useActionState(action, null);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className={cn("space-y-3", className)}>
|
||||||
|
{Object.entries(fields).map(([name, value]) => (
|
||||||
|
<input key={name} type="hidden" name={name} value={value} />
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
<FormMessage state={state} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormMessage({ state }: { state: ActionState }) {
|
||||||
|
if (state?.error) return <ErrorText>{state.error}</ErrorText>;
|
||||||
|
if (state?.notice)
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{state.notice}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorText({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A submit button that shows pending while its form is being sent. With name and value, it's one of several
|
||||||
|
* buttons in the form, and only shows pending when it was the one pressed. With confirm, it asks first.
|
||||||
|
*/
|
||||||
|
export function SubmitButton({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
pendingLabel,
|
||||||
|
confirm,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "type" | "name" | "value" | "onClick"> & {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
pendingLabel?: string;
|
||||||
|
confirm?: string;
|
||||||
|
}) {
|
||||||
|
const status = useFormStatus();
|
||||||
|
const mine = status.pending && (name === undefined || status.data?.get(name) === value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
disabled={status.pending}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (confirm && !window.confirm(confirm)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{mine ? (pendingLabel ?? children) : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Menu } from "@base-ui/react/menu";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** A link in a navigation bar, marked when it's the current page (or, unless exact, a page under it). */
|
||||||
|
export function NavLink({
|
||||||
|
href,
|
||||||
|
exact = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
exact?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const here = pathname === href || (!exact && pathname.startsWith(href + "/"));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={here ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:text-foreground",
|
||||||
|
here && "bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 [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 (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
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"
|
||||||
|
aria-label="Your account"
|
||||||
|
>
|
||||||
|
<Avatar username={username} picture={picture} />
|
||||||
|
<span className="hidden max-w-40 truncate sm:inline">{username}</span>
|
||||||
|
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner align="end" sideOffset={6} className="z-50">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getMe, isModerator } from "@/lib/api";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { NavLink, ProfileMenu } from "./header-client";
|
||||||
|
import logo from "./t5de.png";
|
||||||
|
|
||||||
|
export function Logo({ className }: { className?: string }) {
|
||||||
|
return <Image src={logo} alt="T5DE" priority className={className ?? "size-9"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Header() {
|
||||||
|
const me = await getMe();
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Link href="/" aria-label="T5DE Hub home" className="shrink-0">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
<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="/rooms">Rooms</NavLink>
|
||||||
|
<NavLink href="/products">Products</NavLink>
|
||||||
|
<NavLink href="/catalogs">Catalogs</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
{me ? (
|
||||||
|
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link href="/signup" className={buttonVariants()}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/** A small centered card: sign-in, sign-up, device sign-in, errors. */
|
||||||
|
export function NarrowCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
{children && <CardContent>{children}</CardContent>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
// Who's online in T5DE, kept live: the page renders the list, then this follows the API's event stream.
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Initial, Outfit, Tag } from "@/components/status";
|
||||||
|
import { Since } from "@/components/time";
|
||||||
|
import type { OnlineEntry } from "@/lib/api";
|
||||||
|
|
||||||
|
export function OnlineList({ initial, moderator = false }: { initial: OnlineEntry[]; moderator?: boolean }) {
|
||||||
|
const [online, setOnline] = useState(initial);
|
||||||
|
const [live, setLive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const events = new EventSource("/api/v1/online/events");
|
||||||
|
events.addEventListener("online", (e) => {
|
||||||
|
setOnline((JSON.parse(e.data) as { online: OnlineEntry[] }).online);
|
||||||
|
setLive(true);
|
||||||
|
});
|
||||||
|
events.addEventListener("error", () => setLive(false));
|
||||||
|
return () => events.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={live ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/40"}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{live ? "Live" : "Connecting…"}
|
||||||
|
<span className="ml-auto tabular-nums">
|
||||||
|
{online.length} {online.length === 1 ? "account" : "accounts"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{online.length === 0 ? (
|
||||||
|
<p className="py-6 text-sm text-muted-foreground">Nobody is showing that they're online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{online.map((o) => (
|
||||||
|
<li key={o.imvuId} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={o.username} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{o.username}</span>
|
||||||
|
{o.badge && <Tag tone="strong">T5DE</Tag>}
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online since <Since iso={o.since} />
|
||||||
|
</Tag>
|
||||||
|
{o.hidden && <Tag>hidden: moderators only</Tag>}
|
||||||
|
{moderator && o.adult && <Tag tone="adult">adult</Tag>}
|
||||||
|
{moderator && o.personId && (
|
||||||
|
<Link
|
||||||
|
href={`/console/people/${o.personId}`}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
person
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Outfit items={o.outfit} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned.
|
||||||
|
import type { Link } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const tones = {
|
||||||
|
neutral: "border-border bg-muted/60 text-muted-foreground",
|
||||||
|
strong: "border-foreground/15 bg-foreground/5 text-foreground",
|
||||||
|
online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||||
|
adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
||||||
|
danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Tag({
|
||||||
|
tone = "neutral",
|
||||||
|
dot = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone?: keyof typeof tones;
|
||||||
|
dot?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
|
||||||
|
tones[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round initial standing in for an avatar picture. */
|
||||||
|
export function Initial({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The community products an account wears. */
|
||||||
|
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs text-muted-foreground">Wearing</div>
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
|
||||||
|
{item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's title and the line under it. */
|
||||||
|
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8 space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
|
||||||
|
{children && <div className="text-muted-foreground">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IMVU account's age status: blue when it can see adult community products. */
|
||||||
|
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
|
||||||
|
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
|
||||||
|
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
|
||||||
|
return <Tag>Not age verified</Tag>;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and
|
||||||
|
// the browser replaces them once the page is running.
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const noSubscription = () => () => {};
|
||||||
|
|
||||||
|
/** False on the server and while hydrating, true after. */
|
||||||
|
function useInBrowser(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
noSubscription,
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
["year", 365 * 24 * 3600],
|
||||||
|
["month", 30 * 24 * 3600],
|
||||||
|
["week", 7 * 24 * 3600],
|
||||||
|
["day", 24 * 3600],
|
||||||
|
["hour", 3600],
|
||||||
|
["minute", 60],
|
||||||
|
];
|
||||||
|
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
|
function ago(iso: string, now: number): string {
|
||||||
|
const seconds = (new Date(iso).getTime() - now) / 1000;
|
||||||
|
for (const [unit, size] of units) {
|
||||||
|
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
|
||||||
|
}
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "5 minutes ago", kept up to date. */
|
||||||
|
export function TimeAgo({ iso }: { iso: string }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
const local = useInBrowser();
|
||||||
|
return (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string {
|
||||||
|
return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date (and time). */
|
||||||
|
export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) {
|
||||||
|
const local = useInBrowser();
|
||||||
|
const options: Intl.DateTimeFormatOptions = dateOnly
|
||||||
|
? { dateStyle: "medium" }
|
||||||
|
: { dateStyle: "medium", timeStyle: "short" };
|
||||||
|
return <time dateTime={iso}>{format(iso, options, local)}</time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time of day ("3:04 PM"), with the date too when it isn't today. */
|
||||||
|
export function Since({ iso }: { iso: string }) {
|
||||||
|
const local = useInBrowser();
|
||||||
|
const today = local && new Date(iso).toDateString() === new Date().toDateString();
|
||||||
|
const options: Intl.DateTimeFormatOptions = today
|
||||||
|
? { hour: "numeric", minute: "2-digit" }
|
||||||
|
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
|
||||||
|
return (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{format(iso, options, local)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
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",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof variants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={buttonVariants({ variant, size, className })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NativeSelectProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
data-size={size}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
data-size={size}
|
||||||
|
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"option">) {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
data-slot="native-select-option"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"optgroup">) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Server actions answer forms with an ActionState: an error to show, or a notice.
|
||||||
|
import "server-only";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export type ActionState = { error?: string; notice?: string } | null;
|
||||||
|
|
||||||
|
/** Runs fn, turning the API's errors into a message for the form; fn may return a notice. */
|
||||||
|
export async function attempt(fn: () => Promise<string | void>): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const notice = await fn();
|
||||||
|
return notice ? { notice } : {};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.message };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A form field as a string ("" when missing). */
|
||||||
|
export function field(form: FormData, name: string): string {
|
||||||
|
const v = form.get(name);
|
||||||
|
return typeof v === "string" ? v.trim() : "";
|
||||||
|
}
|
||||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user