Compare commits
23
Commits
7a06ba49c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ebe976ba | ||
|
|
efd5d52198 | ||
|
|
f8ab3f965e | ||
|
|
21e1bd8790 | ||
|
|
975e11e077 | ||
|
|
7d20426d1b | ||
|
|
e3770ceb57 | ||
|
|
97ce2be569 | ||
|
|
07bbc11565 | ||
|
|
76c8e0342e | ||
|
|
43183ec7ee | ||
|
|
f65f36c1ea | ||
|
|
2a1fc94df6 | ||
|
|
0c67232425 | ||
|
|
6668b55117 | ||
|
|
d57674d90c | ||
|
|
51d5bb5ff3 | ||
|
|
b4f6fce9df | ||
|
|
7fea63fc4f | ||
|
|
143aeb3d84 | ||
|
|
6ba4d83317 | ||
|
|
7db9fc0370 | ||
|
|
339ba8e2d6 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
${PullRequestTitle} (#${PullRequestIndex})
|
||||
|
||||
${PullRequestDescription}
|
||||
+37
-1
@@ -1,9 +1,11 @@
|
||||
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub.
|
||||
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub; then, for main and vX.Y.Z
|
||||
# tags, builds its images and pushes them to git.t5de.dev. Komodo deploys them (dhkatz/infra); CI never deploys.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,3 +34,37 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
# main → :latest-main and :main-<commit>; vX.Y.Z → :X.Y.Z, :X.Y and :latest. Built by the runner's Docker.
|
||||
image:
|
||||
needs: test
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { image: t5de-hub-api, dockerfile: apps/api/Dockerfile }
|
||||
- { image: t5de-hub-web, dockerfile: apps/web/Dockerfile }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
IMAGE: git.t5de.dev/t5de/${{ matrix.image }}
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/heads/main) version="main-${GITHUB_SHA::12}"; tags="latest-main $version" ;;
|
||||
refs/tags/v*)
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Not a vX.Y.Z tag: no image"; exit 0; }
|
||||
tags="$version ${version%.*} latest" ;;
|
||||
*) echo "Not main or a tag: no image"; exit 0 ;;
|
||||
esac
|
||||
echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u deploy --password-stdin
|
||||
docker build -f "$DOCKERFILE" --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
|
||||
--label org.opencontainers.image.revision="$GITHUB_SHA" --label org.opencontainers.image.version="$version" \
|
||||
$(for t in $tags; do echo "-t $IMAGE:$t"; done) .
|
||||
for t in $tags; do docker push -q "$IMAGE:$t"; done
|
||||
docker logout git.t5de.dev
|
||||
@@ -0,0 +1,30 @@
|
||||
# A pull request's title becomes its commit on main (squash merges, .gitea/default_merge_message), so it must be a
|
||||
# conventional commit: the release workflow versions releases and writes the changelog from them. Required to merge
|
||||
# (branch protection, dhkatz/infra modules/app-repo).
|
||||
name: Pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Conventional commit title
|
||||
env:
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
types="feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert"
|
||||
conventional="^($types)(\([a-z0-9][a-z0-9./_-]*\))?!?: [^ ]"
|
||||
if ! [[ "$TITLE" =~ $conventional ]]; then
|
||||
echo "::error::The title becomes the commit on main, so it must be a conventional commit: type(scope): summary, e.g. \"feat(api): room bans\" or \"fix: crash on an empty catalog\" (! before the colon for a breaking change). Types: ${types//|/, }."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$TITLE" =~ ^chore\(release\) ]] && [[ "$BRANCH" != release || "$AUTHOR" != release-bot ]]; then
|
||||
echo "::error::chore(release) is release-bot's: merging its release pull request is what tags a release."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $TITLE"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Releases. release-bot keeps one pull request open from the `release` branch, "chore(release): vX.Y.Z": the next
|
||||
# version and its CHANGELOG.md entry, which git-cliff (cliff.toml) works out from the conventional commits on main
|
||||
# since the last tag. Merging it makes release-bot tag vX.Y.Z on main and publish the release notes; the tag builds the
|
||||
# image (ci.yml) and Komodo deploys it to prod (dhkatz/infra). Only release-bot may create v* tags. It acts with its
|
||||
# own token (the org secret RELEASE_TOKEN), not this workflow's: Gitea starts no workflows for what that one does, so
|
||||
# the release pull request's checks and the tag's build would never run.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLIFF_VERSION: 2.14.2
|
||||
CLIFF_SHA512: 95b0c55ab3e351ca7c0285fbbde8f1e8c42906466a173e6884f5c7ca814523bda44cbd4ab26b0aa28cace4c02c5ee6b553e9742bd9d7df47a73b9ef58ea429a9
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
archive="git-cliff-$CLIFF_VERSION-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl -fsSLO "https://github.com/orhun/git-cliff/releases/download/v$CLIFF_VERSION/$archive"
|
||||
echo "$CLIFF_SHA512 $archive" | sha512sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$archive" --strip-components=1 -C "$HOME/.local/bin" "git-cliff-$CLIFF_VERSION/git-cliff"
|
||||
rm "$archive"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Tag a merged release, or update the release pull request
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
host="${GITHUB_SERVER_URL#https://}"
|
||||
git config user.name release-bot
|
||||
git config user.email "release-bot@${host#git.}"
|
||||
basic=$(printf 'release-bot:%s' "$TOKEN" | base64 -w0)
|
||||
push() { git -c http.extraHeader="Authorization: Basic $basic" push "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$@"; }
|
||||
gitea() { curl -fsS -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||
|
||||
# The release pull request was merged: tag its commit and publish the release.
|
||||
if [[ "$(git log -1 --format=%s)" =~ ^chore\(release\):\ (v[0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "$tag exists already"; exit 0; fi
|
||||
git tag "$tag"
|
||||
push "refs/tags/$tag"
|
||||
jq -n --arg tag "$tag" --arg body "$(git cliff --latest --strip all)" '{tag_name: $tag, name: $tag, body: $body}' |
|
||||
gitea -o /dev/null -X POST "$api/releases" -d @-
|
||||
echo "Released $tag"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise: anything to release since the last tag? (Nothing printed when no commit counts.)
|
||||
last=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
|
||||
next=$(git cliff --bumped-version 2>/dev/null || true)
|
||||
next="v${next#v}"
|
||||
if [ "$next" = v ] || [ "$next" = "$last" ]; then echo "Nothing to release since ${last:-the start}"; exit 0; fi
|
||||
|
||||
notes=$(git cliff --unreleased --bump --strip all)
|
||||
git switch -C release
|
||||
git cliff --bump --output CHANGELOG.md
|
||||
git add CHANGELOG.md
|
||||
git commit -q -m "chore(release): $next"
|
||||
push --force HEAD:refs/heads/release
|
||||
|
||||
title="chore(release): $next"
|
||||
body=$(printf 'Merging this releases **%s**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.\n\n%s\n' "$next" "$notes")
|
||||
payload=$(jq -n --arg title "$title" --arg body "$body" '{title: $title, body: $body}')
|
||||
open=$(gitea "$api/pulls?state=open&limit=50" | jq -r '[.[] | select(.head.ref == "release")][0].number // empty')
|
||||
if [ -n "$open" ]; then
|
||||
gitea -o /dev/null -X PATCH "$api/pulls/$open" -d "$payload"
|
||||
echo "Updated the release pull request #$open: $title"
|
||||
else
|
||||
jq '. + {head: "release", base: "main"}' <<<"$payload" | gitea -o /dev/null -X POST "$api/pulls" -d @-
|
||||
echo "Opened the release pull request: $title"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
|
||||
|
||||
## 0.2.0 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **web:** Download link to t5de.dev in the header (#2)
|
||||
|
||||
|
||||
## 0.1.0 (2026-11-02)
|
||||
|
||||
### Badges
|
||||
|
||||
- The proxy asks which IMVU accounts show the T5 badge
|
||||
|
||||
### CI
|
||||
|
||||
- Test against a Postgres service (embedded Postgres won't run as root)
|
||||
|
||||
### Design
|
||||
|
||||
- T5 products in the client's shop and inventory
|
||||
- Everywhere the T5 badge appears
|
||||
|
||||
### Hub
|
||||
|
||||
- Accounts, IMVU linking by tagline, proxy sign-in, presence, console
|
||||
|
||||
### Presence
|
||||
|
||||
- Count an account as soon as it's linked; accept an empty outfit
|
||||
|
||||
### Products
|
||||
|
||||
- Images need https addresses; lowering a rating waits for review; library shows creators
|
||||
- Carry the IMVU parent's image and categories
|
||||
|
||||
### Website
|
||||
|
||||
- Next.js app for accounts, linking, who's online, device sign-in and the console
|
||||
- Products, library, catalogs and their console page
|
||||
|
||||
@@ -10,6 +10,13 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
|
||||
in our own words are fine.
|
||||
- Test with a throwaway IMVU account, never a real one.
|
||||
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
|
||||
- Changes reach main only through pull requests, squash-merged: the PR's title becomes the commit on main, so it must
|
||||
be a conventional commit (`feat(api): …`, `fix(web): …`, `docs: …`; `!` before the colon for a breaking change),
|
||||
checked by `.gitea/workflows/pull-request.yml`. Commits on the branch can be anything. Push a branch and open the
|
||||
pull request on git.t5de.dev (web UI or API); the user merges.
|
||||
- Releases: release-bot keeps a `chore(release): vX.Y.Z` pull request open (`release.yml`, git-cliff from those
|
||||
titles); merging it tags vX.Y.Z, which builds the image and deploys prod. Never tag by hand (v* tags are
|
||||
release-bot's).
|
||||
- IMVU session keys never leave the user's machine; the hub never asks for them.
|
||||
|
||||
## Notes
|
||||
@@ -26,4 +33,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
|
||||
(`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 user API) are rare and rate-limited: linking and age re-checks.
|
||||
- Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms,
|
||||
and the IMVU products T5 products derive from (`parent_info`, looked up once per revision).
|
||||
@@ -17,6 +17,9 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
|
||||
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
|
||||
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
|
||||
use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge.
|
||||
- **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
|
||||
@@ -46,4 +49,7 @@ Needs Bun (1.4) and Node (for Next.js).
|
||||
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
|
||||
public address) and `WEB_URL` (the website's).
|
||||
|
||||
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
|
||||
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
|
||||
|
||||
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
|
||||
+3
-1
@@ -5,7 +5,9 @@ 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/
|
||||
RUN bun install --frozen-lockfile --production --filter @t5de/api
|
||||
# Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated
|
||||
# layout keeps links in each package's own node_modules.
|
||||
RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted
|
||||
|
||||
FROM oven/bun:1.4.4-slim
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
"dependencies": {
|
||||
"@t5de/db": "workspace:*",
|
||||
"better-auth": "1.7.14",
|
||||
"elysia": "1.4.30"
|
||||
"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;
|
||||
}
|
||||
}
|
||||
+984
-44
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
+114
-7
@@ -1,5 +1,6 @@
|
||||
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
|
||||
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
|
||||
// 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;
|
||||
@@ -7,6 +8,29 @@ export type ImvuUser = {
|
||||
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 {
|
||||
@@ -31,11 +55,36 @@ export class ImvuClient {
|
||||
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)}`);
|
||||
return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse);
|
||||
}
|
||||
|
||||
byId(id: number): Promise<ImvuUser> {
|
||||
return this.get(`/user/user-${id}`);
|
||||
return this.get(`/user/user-${id}`).then(parse);
|
||||
}
|
||||
|
||||
/** A room by its instance ID ("<owner>-<n>"); undefined if there's no such room. */
|
||||
async room(instanceId: string): Promise<ImvuRoom | undefined> {
|
||||
if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined;
|
||||
try {
|
||||
return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId);
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchImvuUser) return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
|
||||
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
|
||||
*/
|
||||
async product(id: number): Promise<ImvuProduct | null> {
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
try {
|
||||
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchImvuUser) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async wait() {
|
||||
@@ -45,7 +94,8 @@ export class ImvuClient {
|
||||
if (at > now) await Bun.sleep(at - now);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<ImvuUser> {
|
||||
/** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
|
||||
private async get(path: string, missing: number[] = []): Promise<unknown> {
|
||||
await this.wait();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -56,12 +106,68 @@ export class ImvuClient {
|
||||
} catch (e) {
|
||||
throw new ImvuUnavailable(String(e));
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return parse(await response.json());
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`));
|
||||
const d = entry?.[1].data;
|
||||
if (!d) return undefined;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
let image = str(d.image_url);
|
||||
if (image.startsWith("//")) image = "https:" + image;
|
||||
if (!image.startsWith("https://")) image = "";
|
||||
return {
|
||||
instanceId,
|
||||
ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]),
|
||||
name: str(d.name).slice(0, 200),
|
||||
description: str(d.description).slice(0, 2000),
|
||||
imageURL: image,
|
||||
privacy: str(d.privacy),
|
||||
type: str(d.type),
|
||||
contentRating: str(d.content_rating),
|
||||
accessPass: d.is_ap === true,
|
||||
occupancy: num(d.occupancy),
|
||||
capacity: num(d.capacity),
|
||||
expired: d.is_expired === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
|
||||
export function imvuImage(v: unknown): string | null {
|
||||
if (typeof v !== "string" || v.length > 1000) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(v.startsWith("//") ? "https:" + v : v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
||||
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
|
||||
const d = entry?.[1].data;
|
||||
if (!d) return null;
|
||||
const path = Array.isArray(d.category_path) ? d.category_path : [];
|
||||
const categories = path.slice(0, 10).flatMap((c: unknown) => {
|
||||
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
|
||||
const n = Number(id);
|
||||
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
|
||||
return [{ id: n, name: name.trim().slice(0, 100) }];
|
||||
});
|
||||
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
|
||||
return { image: imvuImage(d.product_image), categories, gender };
|
||||
}
|
||||
|
||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
@@ -77,5 +183,6 @@ export function parse(answer: unknown): ImvuUser {
|
||||
tagline: typeof data.tagline === "string" ? data.tagline : "",
|
||||
ageVerified: data.is_ageverified === true,
|
||||
accessPass: data.is_ap === true,
|
||||
picture: imvuImage(data.thumbnail_url),
|
||||
};
|
||||
}
|
||||
+11
-1
@@ -22,6 +22,16 @@ 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 } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
+632
-23
@@ -1,33 +1,196 @@
|
||||
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
|
||||
// community products it wears. Later, room channels.
|
||||
// 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"}]} the community products it wears now
|
||||
// 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 };
|
||||
export type Item = { id: string; name: string; adult: boolean };
|
||||
|
||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
|
||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
||||
|
||||
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void };
|
||||
/** What realtime needs to know of a hosted room to let someone in. */
|
||||
export type HostedRoomInfo = {
|
||||
closed: boolean;
|
||||
adult: boolean;
|
||||
maxUsers: number;
|
||||
privacy: "public" | "private";
|
||||
ownerImvuId: number;
|
||||
moderators: number[];
|
||||
name: string;
|
||||
ownerName: string;
|
||||
};
|
||||
|
||||
export type Owners = (imvuId: number) => Promise<string | undefined>;
|
||||
/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
|
||||
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
|
||||
|
||||
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
|
||||
export const hostedBase = 2_100_000_000;
|
||||
/** The highest room number: chat IDs must stay XML-RPC integers. */
|
||||
export const maxHostedN = 2_147_483_647 - hostedBase;
|
||||
|
||||
type Participant = { imvu_id: number; seat: number; name: string };
|
||||
|
||||
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
|
||||
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
|
||||
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it
|
||||
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
|
||||
type Session = {
|
||||
userId: string;
|
||||
imvuId: number;
|
||||
requested: number;
|
||||
adult: boolean;
|
||||
since: Date;
|
||||
outfit: Item[];
|
||||
chats: Map<string, string>;
|
||||
lastSent: Map<string, string>;
|
||||
hosted: Set<number>;
|
||||
tokens: number;
|
||||
refilled: number;
|
||||
watching: Set<number>;
|
||||
watchGen: number;
|
||||
invites: number;
|
||||
invitesRefilled: number;
|
||||
send: (message: object) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
|
||||
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
|
||||
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
|
||||
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
|
||||
|
||||
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
|
||||
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
|
||||
|
||||
export type RealtimeDeps = {
|
||||
/** The person an IMVU account is linked to. */
|
||||
owner: (imvuId: number) => Promise<string | undefined>;
|
||||
/** Whether a person's IMVU account counts as adult (the age rule). */
|
||||
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
||||
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
||||
room?: (instanceId: string) => void;
|
||||
/** A hosted room (undefined: there's none by that number). */
|
||||
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
|
||||
/** Whether an IMVU account is banned from a hosted room now. */
|
||||
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
|
||||
/** Someone was booted (and maybe banned): store the ban, log it. */
|
||||
hostedBoot?: (boot: Boot) => Promise<void>;
|
||||
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
|
||||
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
|
||||
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
|
||||
accountName?: (imvuId: number) => Promise<string>;
|
||||
};
|
||||
|
||||
const maxChats = 16;
|
||||
const chatPattern = /^[0-9]{1,20}$/;
|
||||
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
||||
|
||||
const maxHostedPerSession = 5;
|
||||
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
|
||||
const sendBurst = 20;
|
||||
const sendPerSecond = 10;
|
||||
const maxWatched = 100;
|
||||
const inviteLifetime = 10 * 60 * 1000;
|
||||
const inviteBurst = 10;
|
||||
const invitesPerMinute = 10;
|
||||
const maxNote = 200;
|
||||
const maxBanMinutes = 10 * 366 * 24 * 60;
|
||||
|
||||
const roomErrors = {
|
||||
not_linked: "Sign in to T5DE with a linked IMVU account first.",
|
||||
not_found: "No such room.",
|
||||
closed: "This room is closed.",
|
||||
adult: "This room is for adults.",
|
||||
full: "This room is full.",
|
||||
too_many: "You're in too many rooms.",
|
||||
banned: "You're banned from this room.",
|
||||
private: "This room is private.",
|
||||
} as const;
|
||||
|
||||
// Reasons in room.boot_result and room.invite_result.
|
||||
const actionErrors = {
|
||||
not_linked: roomErrors.not_linked,
|
||||
not_found: roomErrors.not_found,
|
||||
closed: roomErrors.closed,
|
||||
forbidden: "You can't do that in this room.",
|
||||
not_in_room: "Join the room first.",
|
||||
banned: "They're banned from this room.",
|
||||
adult: "This room is for adults.",
|
||||
rate_limited: "Too many invitations. Try again in a minute.",
|
||||
} as const;
|
||||
|
||||
export class Realtime {
|
||||
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(private readonly owner: Owners) {}
|
||||
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
||||
const d = typeof deps === "function" ? { owner: deps } : deps;
|
||||
this.deps = {
|
||||
adult: async () => false,
|
||||
room: () => {},
|
||||
hostedRoom: async () => undefined,
|
||||
hostedBanned: async () => false,
|
||||
hostedBoot: async () => {},
|
||||
hostedVisit: () => {},
|
||||
accountName: async () => "",
|
||||
...d,
|
||||
};
|
||||
}
|
||||
|
||||
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
|
||||
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private changed() {
|
||||
private changed(chats: Iterable<string> = []) {
|
||||
for (const chat of new Set(chats)) this.sync(chat);
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
@@ -38,7 +201,8 @@ export class Realtime {
|
||||
if (!s.imvuId) continue;
|
||||
const prev = byId.get(s.imvuId);
|
||||
if (prev && prev.since > s.since) continue;
|
||||
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
|
||||
const rooms = [...s.chats.values()].filter(Boolean);
|
||||
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
|
||||
}
|
||||
@@ -47,14 +211,80 @@ export class Realtime {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
open(id: string, userId: string, close: () => void) {
|
||||
this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close });
|
||||
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?.imvuId) this.changed();
|
||||
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||
}
|
||||
|
||||
/** A person just linked an IMVU account: their clients already using it count now. */
|
||||
async linked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
|
||||
s.imvuId = imvuId;
|
||||
s.since = new Date();
|
||||
s.adult = await this.deps.adult(userId, imvuId);
|
||||
chats.push(...s.chats.keys());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.changed(chats);
|
||||
}
|
||||
|
||||
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
|
||||
unlinked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.userId === userId && s.imvuId === imvuId) {
|
||||
this.leaveAllHosted(id, s, true);
|
||||
s.imvuId = 0;
|
||||
s.adult = false;
|
||||
s.outfit = [];
|
||||
chats.push(...s.chats.keys());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.changed(chats);
|
||||
}
|
||||
|
||||
/** Sends a message to all of a person's connections. */
|
||||
notify(userId: string, message: object) {
|
||||
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
|
||||
}
|
||||
|
||||
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
|
||||
decorated(instance: string) {
|
||||
const message = { type: "decor", room: instance };
|
||||
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
|
||||
}
|
||||
|
||||
/** Closes a person's connections (banned, or signed out of their computers). */
|
||||
@@ -62,35 +292,414 @@ export class Realtime {
|
||||
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 };
|
||||
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);
|
||||
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
|
||||
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
||||
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
|
||||
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
|
||||
if (owner !== s.userId) {
|
||||
// Not this person's account: no presence, no outfit for it.
|
||||
s.imvuId = 0;
|
||||
s.adult = false;
|
||||
s.outfit = [];
|
||||
this.changed();
|
||||
this.changed(s.chats.keys());
|
||||
return { type: "error", message: "not_linked" };
|
||||
}
|
||||
s.imvuId = imvuId;
|
||||
s.since = new Date();
|
||||
s.outfit = [];
|
||||
this.changed();
|
||||
s.adult = await this.deps.adult(s.userId, imvuId);
|
||||
this.changed(s.chats.keys());
|
||||
return;
|
||||
}
|
||||
if (m.type === "outfit") {
|
||||
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" };
|
||||
s.outfit = m.items
|
||||
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 }));
|
||||
this.changed();
|
||||
.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" };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -130,14 +130,14 @@ 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 });
|
||||
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 });
|
||||
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(
|
||||
@@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => {
|
||||
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 });
|
||||
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);
|
||||
@@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => {
|
||||
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 });
|
||||
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");
|
||||
});
|
||||
@@ -170,7 +170,7 @@ 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 });
|
||||
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`, {
|
||||
@@ -213,6 +213,13 @@ describe("T5DE's proxy", () => {
|
||||
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));
|
||||
@@ -227,7 +234,7 @@ describe("T5DE's proxy", () => {
|
||||
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" }] },
|
||||
{ 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export async function linkAction(_: ActionState, form: FormData): Promise<Action
|
||||
|
||||
export async function saveSettings(
|
||||
imvu: number,
|
||||
settings: { showBadge?: boolean; showPresence?: boolean },
|
||||
settings: { showBadge?: boolean; showPresence?: boolean; listRooms?: boolean },
|
||||
): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||
|
||||
@@ -27,7 +27,12 @@ export function LinkCard({ link }: { link: Link }) {
|
||||
</div>
|
||||
</div>
|
||||
<Outfit items={link.outfit} />
|
||||
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
|
||||
<LinkSettings
|
||||
imvuId={link.imvuId}
|
||||
showBadge={link.showBadge}
|
||||
showPresence={link.showPresence}
|
||||
listRooms={link.listRooms}
|
||||
/>
|
||||
</div>
|
||||
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import type { ActionState } from "@/lib/actions";
|
||||
import { saveSettings } from "./actions";
|
||||
|
||||
type Settings = { showBadge: boolean; showPresence: boolean };
|
||||
type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean };
|
||||
|
||||
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
||||
const [settings, setOptimistic] = useOptimistic(saved);
|
||||
@@ -29,6 +29,10 @@ export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }
|
||||
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
@@ -42,3 +42,13 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function ConsoleNav() {
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Where creating a product used to be.
|
||||
export default function Create() {
|
||||
redirect("/products/new");
|
||||
}
|
||||
@@ -128,4 +128,37 @@
|
||||
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 |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<style>
|
||||
rect { fill: #171717; } text { fill: #fafafa; }
|
||||
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
|
||||
</style>
|
||||
<rect width="32" height="32" rx="7"/>
|
||||
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -16,6 +17,7 @@ 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">
|
||||
|
||||
@@ -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,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,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>;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -32,21 +33,67 @@ export function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutButton() {
|
||||
/** The signed-in person's menu: their picture and name, opening their pages and signing out. */
|
||||
export function ProfileMenu({ username, picture, console }: { username: string; picture: string | null; console: boolean }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const item =
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-muted";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getMe, isModerator } from "@/lib/api";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NavLink, SignOutButton } from "./header-client";
|
||||
import { NavLink, ProfileMenu } from "./header-client";
|
||||
import logo from "./t5de.png";
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<span
|
||||
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
|
||||
aria-hidden
|
||||
>
|
||||
T5
|
||||
</span>
|
||||
);
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return <Image src={logo} alt="T5DE" priority className={className ?? "size-9"} />;
|
||||
}
|
||||
|
||||
export async function Header() {
|
||||
@@ -19,21 +14,21 @@ export async function Header() {
|
||||
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="/" className="flex items-center gap-2 font-semibold">
|
||||
<Link href="/" aria-label="T5DE Hub home" className="shrink-0">
|
||||
<Logo />
|
||||
T5DE Hub
|
||||
</Link>
|
||||
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||
<nav className="order-last flex w-full flex-wrap items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||
<NavLink href="/online">Online</NavLink>
|
||||
{me && <NavLink href="/account">Account</NavLink>}
|
||||
{isModerator(me) && <NavLink href="/console">Console</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">
|
||||
<a href="https://t5de.dev/download" className={buttonVariants({ variant: "ghost" })}>
|
||||
Download
|
||||
</a>
|
||||
{me ? (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
|
||||
<SignOutButton />
|
||||
</>
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
const variants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
@@ -39,16 +39,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
// Merged, so a variant's border replaces the base's transparent one on links styled as buttons too.
|
||||
const buttonVariants = (props?: Parameters<typeof variants>[0]) => cn(variants(props))
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof variants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={buttonVariants({ variant, size, className })}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
-1
@@ -40,6 +40,12 @@ export type Challenge = Account["challenges"][number];
|
||||
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||
export type AuditEntry = ConsoleData["audit"][number];
|
||||
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
|
||||
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||
export type Revision = ProductPage["revisions"][number];
|
||||
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
||||
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
||||
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
|
||||
|
||||
/** An error the API answered: message is written to be shown as is. */
|
||||
export class ApiError extends Error {
|
||||
@@ -75,4 +81,4 @@ export const getMe = cache(async (): Promise<Person | undefined> => {
|
||||
return data && "person" in data ? data.person : undefined;
|
||||
});
|
||||
|
||||
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||
export const isModerator = (p: Pick<Person, "role"> | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||
@@ -0,0 +1,41 @@
|
||||
// A Content Security Policy for every page: scripts only from this site with the request's nonce (Next.js adds it to
|
||||
// its own scripts), so HTML that slips into a page, such as a product's description, can't run anything. Uploads go
|
||||
// from the browser straight to catalogs, so connect-src allows any https address (and local ones, for development).
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const dev = process.env.NODE_ENV === "development";
|
||||
const policy = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
|
||||
// Descriptions keep their style attributes (the API's allowlist limits them to colors, sizes and alignment).
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' https: data: blob:",
|
||||
"font-src 'self'",
|
||||
`connect-src 'self' https: http://localhost:* http://127.0.0.1:*${dev ? " ws:" : ""}`,
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("x-nonce", nonce);
|
||||
headers.set("Content-Security-Policy", policy);
|
||||
const response = NextResponse.next({ request: { headers } });
|
||||
response.headers.set("Content-Security-Policy", policy);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
{
|
||||
source: "/((?!api|_next/static|_next/image|icon.svg|favicon.ico).*)",
|
||||
missing: [
|
||||
{ type: "header", key: "next-router-prefetch" },
|
||||
{ type: "header", key: "purpose", value: "prefetch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -14,9 +14,12 @@
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
@@ -391,6 +394,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@types/sanitize-html": ["@types/[email protected]", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="],
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
@@ -497,6 +502,8 @@
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dayjs": ["[email protected]", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
|
||||
|
||||
"debounce-fn": ["[email protected]", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
@@ -517,6 +524,14 @@
|
||||
|
||||
"diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
||||
|
||||
"domelementtype": ["[email protected]", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||
|
||||
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||
|
||||
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
|
||||
|
||||
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
|
||||
|
||||
"dotenv": ["[email protected]", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
@@ -535,12 +550,16 @@
|
||||
|
||||
"enquirer": ["[email protected]", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
@@ -587,6 +606,8 @@
|
||||
|
||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
|
||||
|
||||
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
@@ -613,6 +634,8 @@
|
||||
|
||||
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
|
||||
|
||||
"is-regexp": ["[email protected]", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
|
||||
|
||||
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
@@ -643,6 +666,8 @@
|
||||
|
||||
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
|
||||
|
||||
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -723,6 +748,8 @@
|
||||
|
||||
"parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
|
||||
|
||||
"path-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||
@@ -735,7 +762,7 @@
|
||||
|
||||
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"postcss": ["[email protected].23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
"postcss": ["[email protected]2", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="],
|
||||
|
||||
@@ -769,6 +796,8 @@
|
||||
|
||||
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"sanitize-html": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -883,6 +912,8 @@
|
||||
|
||||
"@shadcn/registry/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
|
||||
|
||||
"@shadcn/registry/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
@@ -895,6 +926,8 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tailwindcss/postcss/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"conf/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -909,6 +942,8 @@
|
||||
|
||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
@@ -919,6 +954,8 @@
|
||||
|
||||
"shadcn/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
|
||||
|
||||
"shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"tsx/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# git-cliff (https://git-cliff.org), for the release workflow (.gitea/workflows/release.yml): the next version and
|
||||
# CHANGELOG.md, from the conventional commits on main (one per pull request, titled by it) since the last vX.Y.Z tag.
|
||||
# feat → minor, fix/perf/revert → patch, a breaking change (`!` or BREAKING CHANGE:) → minor while 0.x, major after.
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
|
||||
"""
|
||||
body = """
|
||||
{% if commits | length > 0 %}
|
||||
{% if version %}## {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }}){% else %}## Unreleased{% endif %}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | upper_first }}{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
"""
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
protect_breaking_commits = true
|
||||
commit_parsers = [
|
||||
{ message = "^chore\\(release\\)", skip = true },
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^revert", group = "<!-- 3 -->Reverts" },
|
||||
{ message = "^refactor", group = "<!-- 4 -->Refactoring" },
|
||||
{ message = "^docs", group = "<!-- 5 -->Documentation" },
|
||||
{ message = "^(build|ci|chore|style|test)", skip = true },
|
||||
]
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+"
|
||||
sort_commits = "oldest"
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "v0.1.0"
|
||||
# Matched against the commit's type: these alone don't make a release.
|
||||
no_increment_regex = "^(chore|ci|docs|test|style|build|refactor)$"
|
||||
+58
-4
@@ -11,7 +11,7 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session tok
|
||||
|
||||
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
|
||||
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`.
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`.
|
||||
|
||||
## Accounts (Better Auth)
|
||||
|
||||
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
|
||||
| `POST /api/auth/sign-in/username` | `{username, password}` |
|
||||
| `POST /api/auth/sign-out` | |
|
||||
| `GET /api/v1/me` | `{person}` |
|
||||
| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
|
||||
|
||||
## Proxies
|
||||
|
||||
@@ -29,7 +29,8 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
|
||||
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
|
||||
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
|
||||
| `GET /api/v1/realtime` | WebSocket, bearer token. Protocol in `apps/api/src/realtime.ts`. |
|
||||
| `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. |
|
||||
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel, decoration notices (`decor`) and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. |
|
||||
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
||||
|
||||
## Website routes (signed in unless noted)
|
||||
@@ -39,15 +40,68 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `GET /api/v1/home` | (anyone) `{onlineCount, links?}` |
|
||||
| `GET /api/v1/online` | (anyone) `{online}`: accounts showing they're online (moderators see all, with `hidden`) |
|
||||
| `GET /api/v1/online/events` | (anyone) server-sent events: `online` with `{online}` now and on each change |
|
||||
| `GET /api/v1/rooms` | (anyone) `{rooms}`: listed IMVU rooms of T5DE users, with the T5DE users in each now (AP rooms for adults only) |
|
||||
| `GET /api/v1/account` | `{person, links, challenges, computers, canLink}` |
|
||||
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
|
||||
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
|
||||
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
|
||||
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?}` → `{link}` |
|
||||
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` (each off by default; `showBadge`: others see the T5 badge on this account in the client) |
|
||||
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
|
||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
||||
|
||||
## Hosted rooms
|
||||
|
||||
Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session;
|
||||
the `GET`s also signed out.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
|
||||
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. |
|
||||
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
|
||||
| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" |
|
||||
|
||||
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||
privacy, moderators, moderatorNames, image, favorite, score, ratings,
|
||||
createdAt}`; `instanceId` is `<owner IMVU ID>-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance,
|
||||
`<owner's customer ID>-<n>` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (room number 2100000000 and up) are refused
|
||||
(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents`
|
||||
is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the
|
||||
sha256 (hex) of `contents` as JSON.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. |
|
||||
| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked |
|
||||
| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. |
|
||||
|
||||
## Products, catalogs and libraries
|
||||
|
||||
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
||||
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the
|
||||
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
|
||||
category ID) and `limit` (1-200, default 60).
|
||||
|
||||
## Age verification
|
||||
|
||||
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
|
||||
person-level providers (phone wallets, document checks) are started here.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks |
|
||||
| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) |
|
||||
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
|
||||
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature |
|
||||
|
||||
## Console (moderators; roles: admins)
|
||||
|
||||
| | |
|
||||
|
||||
+90
-14
@@ -101,16 +101,43 @@ age, not verified) and `tagline`.
|
||||
offer to link. Linked to another hub account: no community features that session.
|
||||
- **Bans** apply to the person, so all linked accounts.
|
||||
|
||||
## Age (**Decided**: IMVU's verification)
|
||||
## Age (**Decided**: free, several providers, vendor-neutral)
|
||||
|
||||
- Adult content requires the **IMVU account in use** to be age verified (`is_ageverified`) or to have Access Pass
|
||||
(`is_ap`; **Decided**: it counts), checked by the hub
|
||||
itself (never reported by the proxy, which anyone can change): at linking and at most daily after, cached.
|
||||
Revocations show up at the next check.
|
||||
- Per IMVU account, not per person, so one verified account doesn't unlock others a minor might use.
|
||||
No payment for verification (adult content rules out mainstream merchants of record; see the discussion of
|
||||
2026-10-29). Several ways to prove age, tried in order of privacy, behind one interface so any can be swapped:
|
||||
|
||||
1. **IMVU** (live): the IMVU account is age verified (`is_ageverified`) or has Access Pass (`is_ap`), read by the hub
|
||||
from IMVU's public API. Scope: that IMVU account. Re-checked about daily; a revocation shows at the next check.
|
||||
Self-reported `is_adult` doesn't count.
|
||||
2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet,
|
||||
asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or
|
||||
birth date reach it. Scope: the person.
|
||||
3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person
|
||||
(the liveness step stops someone using another person's ID).
|
||||
|
||||
### How it's built
|
||||
|
||||
- **One table of results**, `age_check`: subject (a person, or one of their linked IMVU accounts), provider,
|
||||
method (e.g. `imvu_age_verified`, `imvu_access_pass`, `wallet_mdoc`, `id_document`), status (`pending`,
|
||||
`complete`, `failed`, `revoked`), over 18 yes/no, the provider's reference, when it was checked and when it
|
||||
expires. Never a birth date, document, photo or name: providers that see those keep them.
|
||||
- **One rule**: an IMVU account counts as adult if its person has a current complete check that says over 18, or
|
||||
the account itself has one. Everything (website, tokens for catalogs, room channels) asks this one function.
|
||||
- **Providers** implement a small interface: `start` (begin a check: done at once, a page to send the person to,
|
||||
or a request for the browser to hand to a wallet), `complete` (finish from the provider's callback or signed
|
||||
webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is
|
||||
configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
|
||||
one new adapter; nothing else changes.
|
||||
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature.
|
||||
- Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room
|
||||
channels for viewers who aren't verified and opted in), and the viewer's proxy.
|
||||
- Self-reported `is_adult` doesn't count.
|
||||
channels for viewers who aren't adult and opted in), and the viewer's proxy.
|
||||
|
||||
### To add the planned providers
|
||||
|
||||
- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning
|
||||
only "over 18". Confirm its free monthly allowance covers age checks.
|
||||
- Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP,
|
||||
mdoc); Apple and the EU wallet through the same browser API where supported.
|
||||
|
||||
## Products and catalogs
|
||||
|
||||
@@ -207,6 +234,31 @@ number, `productdata/<id>/<revision>/`) but hardly shows them; T5DE shows them o
|
||||
- **Seeing versus wearing** (**Decided**): anyone can *see* any catalog's products on someone else (subject to their
|
||||
own age settings); getting and wearing them needs membership where the catalog requires it.
|
||||
|
||||
## In the client's shop and inventory (planned, the user's goal)
|
||||
|
||||
- **"Buying" is saving**: getting a T5 product (free) adds it to the person's library on the hub. The proxy shows
|
||||
library products in the client's own inventory, next to IMVU's, so they're worn like any other.
|
||||
- **Browsing in the shop**: T5 products appear in the client's shop, with a T5 filter beside IMVU's rating filter
|
||||
and a T5 badge on T5 products (inventory, shop, product cards). Images are drawn and kept for this (the user's
|
||||
Pictures\T5DE badges, made by its `draw.py`): badges 18x14, 25x21 and 23x19; filter strips for IMVU's two
|
||||
layouts with T5 added last (`GA|all|AP|T5`, `GA|AP|AP+|all|T5`) and `all|T5` for people without Access Pass,
|
||||
who get no rating filter today (T5 products aren't only adult ones).
|
||||
- **Where the T5 badge goes** (every place the client shows AP/AP+/VIP badges, checked in the client's UI files):
|
||||
- *Products and outfits* (always, a property of the product): shop results and product pages, inventory,
|
||||
the outfits viewer and outfit cards (an outfit with any T5 product gets it, as AP outfits do), product
|
||||
recommendation widgets, checkout ("getting" a T5 product).
|
||||
- *People* (only if they turned on "Show T5DE badge" for that IMVU account, the same choice everywhere): avatar
|
||||
cards, the friends list (page and sidebar), the right-click menu on an avatar, chat names, the inbox, the
|
||||
"invited to chat" and Shop Together invite dialogs, the gifting friend picker, home, and Find People and Get
|
||||
Matched (with a "T5DE users" filter there).
|
||||
- *Rooms* (always, not optional: visitors need to know a room needs T5DE to be seen as meant; hosted rooms, and
|
||||
IMVU rooms decorated with T5 products): the chat rooms browser, room cards, the owner's room manager, and the
|
||||
room's tab.
|
||||
The client learns who and what is T5 from the proxy, which asks the hub (badge choices, products, rooms).
|
||||
- Needs: the hub's product registry and catalogs serving files (so products don't have to be local packs), the
|
||||
library on the hub, the proxy answering the inventory's and shop's calls for T5 products, and a patch to the
|
||||
shop page for the filter and badges.
|
||||
|
||||
## Outfits
|
||||
|
||||
1. The user puts on a community item. The client shows it (proxy answers for it); the patch sends IMVU the outfit
|
||||
@@ -216,13 +268,27 @@ number, `productdata/<id>/<revision>/`) but hardly shows them; T5DE shows them o
|
||||
3. Other T5DE users' proxies add the items to that user's incoming `*use`, filtered by their own age settings.
|
||||
Non-T5DE users see the default.
|
||||
|
||||
How it's built (2026-10-29):
|
||||
- The proxy sees the client's IMQ subscriptions to chat queues (`/chat/<id>`) and tells the hub which chats it's
|
||||
in; for a room's chat, also the room (from `chat.getOrMakeChat`, `activity: publicroom-<owner>-<n>`).
|
||||
- The hub tells each proxy in a chat about the other T5DE users there and what they wear (adult products only to
|
||||
clients whose account counts as adult), on joining and on every change.
|
||||
- The proxy delivers `*use` / `*remove` lines from those users to the client (IMQ messages of its own, without a
|
||||
sequence number, which the client doesn't track), and adds their community products to the whole outfits they
|
||||
send (`*use` with the body, re-sent on joins and every 30 s or so), which would otherwise put IMVU's back.
|
||||
- Products the viewer has no pack for are skipped (logged), until catalogs serve them.
|
||||
- The client also posts its outfit to IMVU's chat participant endpoint (`legacy_outfit_message`); the proxy takes
|
||||
community products out of it and re-signs it.
|
||||
- Anyone signed in can say they're in a chat; what they learn is what T5DE users in it wear, which anyone in the
|
||||
chat sees anyway.
|
||||
|
||||
Saved outfits are per IMVU account (avatars differ). The library (products added, favorites, followed creators)
|
||||
is per person, shared across their accounts.
|
||||
|
||||
## Rooms (**Decided**: both)
|
||||
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements
|
||||
travel on the room channel like outfits.
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps
|
||||
them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations").
|
||||
- **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions,
|
||||
bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of
|
||||
IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the
|
||||
@@ -230,10 +296,17 @@ is per person, shared across their accounts.
|
||||
- **Chat retention** (**Decided**): not stored by default. When someone reports a conversation, the recent messages
|
||||
around the report are kept as evidence for 30 days, then deleted.
|
||||
- Room shells and furniture are ordinary products.
|
||||
- **Rooms list** (2026-10-29, the user's request): the hub's Rooms page lists IMVU rooms of T5DE users that are
|
||||
public, listed and not expired on IMVU (`privacy: public`, `type: listed`), and whose owner turned on "List my
|
||||
public rooms" for that IMVU account (off by default, like the badge). IMVU's public API can't list a user's rooms,
|
||||
so the hub learns of a room when a T5DE user enters it, asks IMVU about it, and asks again hourly while it's
|
||||
used; rooms nobody entered for 30 days drop off. AP rooms are shown only to people with an adult account. Each
|
||||
shows the T5DE users in it now who show they're online.
|
||||
|
||||
## Presence and badge (**Decided**: both opt-in, per IMVU account)
|
||||
|
||||
- **Badge**: other T5DE users see that this account uses T5DE.
|
||||
- **Badge**: other T5DE users see that this account uses T5DE. The proxy asks the hub which of the accounts it's
|
||||
showing have it (`POST /api/v1/badges`, docs/api.md).
|
||||
- **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't
|
||||
store friend lists, but it sees them when asked (stated in the privacy text).
|
||||
|
||||
@@ -336,9 +409,12 @@ The proxy streams and adds no waiting.
|
||||
## Status
|
||||
|
||||
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
|
||||
- Phase 2 (hub identity) under way: accounts, tagline linking, age, link caps, device sign-in, presence and badge,
|
||||
console. Not yet: periodic age re-checks (on demand only), a sign-in button in the client (the proxy's `signin`
|
||||
command for now), deployment.
|
||||
- Phase 2 (hub identity) under way: accounts, tagline linking, age (vendor-neutral, IMVU's re-checked daily), link
|
||||
caps, device sign-in, presence and badge, console, the room channel and the rooms list. Not yet: a sign-in button
|
||||
in the client (the proxy's `signin` command for now), deployment.
|
||||
- Hosted rooms (phase 5, docs/hosted-rooms.md): the hub's side is done (rooms made on the website, REST, the
|
||||
realtime `room.*` protocol, tested with fake proxies); the proxy's side is under way. The room channel is tested with
|
||||
fake proxies and one live client; two live clients need a second throwaway account.
|
||||
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
|
||||
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
|
||||
document says Go or templ for the hub, read that; catalogs are still to be decided (a Bun program compiled to one
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# T5DE-hosted rooms (phase 1)
|
||||
|
||||
A hosted room lives on the hub (design.md, Rooms): T5DE users find it in the client's room list, join it, sit and
|
||||
chat, and its owner decorates it. The client treats it as an ordinary IMVU room: T5DE's proxy answers the client's
|
||||
room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what the client expects). IMVU never
|
||||
hears of hosted rooms.
|
||||
|
||||
Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the
|
||||
owner's furniture), occupancy in the room list. Phase 2 (below): moderators, boots and bans, private rooms,
|
||||
invitations, images, favorites, ratings, recent rooms, occupancy pushed to watchers, participant names.
|
||||
|
||||
## IDs
|
||||
|
||||
IDs IMVU won't use, so the proxy can tell hosted rooms apart:
|
||||
|
||||
- A room has a number `n` (1, 2, ...), its **instance ID** is `<owner's IMVU ID>-<2100000000 + n>` and its
|
||||
**chat ID** is `2100000000 + n`. The client takes a room's owner from the instance ID's first part (so the owner
|
||||
can decorate it); IMVU's own room numbers are small. (IMVU's chat IDs were about 1,390,000,000 in 2026-10, and XML-RPC integers end at
|
||||
2,147,483,647; IMVU customer IDs, the first part of its instance IDs, are far below 2,100,000,000.)
|
||||
- The room shell is an IMVU product (`roomPid`), chosen when the room is made (any room product; the client loads
|
||||
it like any room).
|
||||
|
||||
## REST (bearer or cookie session)
|
||||
|
||||
| Route | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | `{name, description, roomPid, rating, maxUsers, imvuId}`: make a room. `imvuId` is one of the person's linked IMVU accounts, shown as the owner. `rating` `general` or `adult` (adult needs an adult person). `maxUsers` 2-30, default 10. Name 1-64 characters, description up to 512. A person may own up to 10 open rooms. |
|
||||
| `GET /api/v1/hosted-rooms` | (also signed out) `{rooms: [room]}`, open rooms, newest first; adult rooms only for adults. `?mine=1`: the person's own. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | (also signed out) `{room, scene: {contents, hash}}`; 404 `not_found` (or adult, for others), 410 `closed`. |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | owner: `{name?, description?, rating?, maxUsers?, roomPid?}`. |
|
||||
| `PUT /api/v1/hosted-rooms/:n/scene` | owner: `{contents, roomPid?}`, from the client's `test.setRoomState`. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n` | owner (or moderator console): closes it; everyone in it is told (`room.closed`). |
|
||||
|
||||
A room: `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||
createdAt}` (`ownerName`: the owner's IMVU name as linked; `occupancy`: people in it now). A room whose owner account
|
||||
is no longer linked to its owner (unlinked, or moved to another person), or whose owner is banned, is hidden (not
|
||||
listed, `not_found`) until it's linked again. Closed rooms stay closed; their numbers aren't reused.
|
||||
|
||||
The scene's `contents` is the client's `room_contents`: a list of `[furniture instance ID (integer), product ID
|
||||
(integer or a hub product ID string), placement (string)]`, up to 500 items, placement up to 200 characters. `hash`
|
||||
is a hash of the stored scene (the client caches scenes by it): sha256, hex, of `[roomPid, contents]` as JSON.
|
||||
|
||||
## Realtime (the proxy's WebSocket)
|
||||
|
||||
From the proxy (the session's IMVU account, sent with `session`, must be linked to the person):
|
||||
|
||||
- `{"type": "room.join", "room": n}`: join. Answer `{"type": "room.joined", "room": n, "chat": chatId,
|
||||
"participants": [{"imvu_id", "seat"}], "sequence": s}` (the room's latest message number), or
|
||||
`{"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" | "not_linked" | "too_many",
|
||||
"message": "..."}` (`too_many`: the session is in 5 hosted rooms already). Joining again (a reconnect) answers the
|
||||
same. A newcomer's seat is 0 until it sends `room.seat`. `maxUsers` counts IMVU accounts, so the same account
|
||||
joining from a second session isn't refused as full.
|
||||
- `{"type": "room.leave", "room": n}`.
|
||||
- `{"type": "room.seat", "room": n, "seat": k}`: the person sat down (`*seat k`); 0-99.
|
||||
- `{"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}` (up to 64 KB): to everyone
|
||||
else in the room. For `messages` the hub checks the JSON's `userId` is the sender's IMVU account and drops it
|
||||
otherwise (silently). Each session may send bursts of 20, then 10 a second; more is dropped. Malformed messages
|
||||
(bad room, seat, mount, a message over 64 KB, sending to a room it isn't in) get `{"type": "error", "message"}`.
|
||||
|
||||
From the hub:
|
||||
|
||||
- `{"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}`: numbered
|
||||
per room, from 1.
|
||||
- `{"type": "room.participants", "room": n, "participants": [{"imvu_id", "seat"}]}`: whenever someone joins, leaves
|
||||
or sits (not to the one joining: `room.joined` has the list).
|
||||
- `{"type": "room.closed", "room": n}`: the room closed, or this session is out of it: its IMVU account was unlinked
|
||||
or it sent `session` with another account, or the room became adult and its account isn't. (A lower `maxUsers`
|
||||
sends no one out; it only stops newcomers.)
|
||||
|
||||
A session closing leaves its rooms. The hub keeps the participants and message numbers in memory; messages aren't
|
||||
stored (design.md: chat retention).
|
||||
|
||||
## The proxy's part
|
||||
|
||||
- **XML-RPC** for hosted IDs, answered by the proxy (never sent to IMVU): `test.getRoomCardInfo`,
|
||||
`chat.getOrMakeChat` (activity `publicroom-<owner>-<2100000000+n>`: joins, answers `chatId`, `seat`, `lastMessageId`,
|
||||
`participantUrl`), `test.getRoomState` (the scene, with community products as local IDs), `chat.getParticipants`,
|
||||
`chat.terminateChat`, `test.setRoomState` (owners: saved with `PUT .../scene`).
|
||||
- **IMQ**: subscribing to `/chat/<chatId>` of a hosted room is answered by the proxy (result, `JoinedQueue`,
|
||||
`CreateMount`s for `control`, `messages`, `participants`); `SendMessage` on it goes to the hub as `room.send`, and
|
||||
`room.message` comes back as `G2cSendMessage`. `inv:/room/room-<owner>-<2100000000+n>` is answered with a result and a
|
||||
`node` mount, and nothing more.
|
||||
- **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/<owner>-<2100000000+n>`
|
||||
queues are answered from `room.participants`.
|
||||
|
||||
## Phase 2
|
||||
|
||||
Moderators, boots and bans, private rooms, invitations, images, favorites, ratings, recent rooms, occupancy pushed
|
||||
to watchers, and participants' names. Everything in phase 1 still holds; this adds to it.
|
||||
|
||||
### Rooms (REST)
|
||||
|
||||
A room (in every answer that has one) also has:
|
||||
|
||||
| Field | |
|
||||
| --- | --- |
|
||||
| `privacy` | `"public"` (default) or `"private"` |
|
||||
| `moderators` | IMVU account IDs, up to 20, in the order the owner gave them (never the owner's own account) |
|
||||
| `moderatorNames` | their IMVU names, same order: the name of the linked account, or as IMVU gave it when added by name; `""` when not known |
|
||||
| `image` | `<API URL>/api/v1/hosted-rooms/<n>/image?v=<16 hex>` (absolute; `v` changes with the image), or `null` |
|
||||
| `favorite` | whether it's one of the signed-in person's favorites (`false` signed out) |
|
||||
| `score` | the average rating, 1-5, two decimals; `0` if nobody rated it. (Named `score`, not `rating`: `rating` is already the room's `general`/`adult`.) |
|
||||
| `ratings` | how many people rated it |
|
||||
|
||||
Who's who: the room's **owner** is, over REST, the person who made it; over realtime, its owner IMVU account
|
||||
(`ownerImvuId`). A **moderator** is an IMVU account in `moderators`; over REST, a person is a moderator if one of their
|
||||
linked accounts is.
|
||||
|
||||
| Route | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | also takes `privacy?` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) also takes `privacy?` and `moderators?`: up to 20 entries, each an IMVU ID (integer, or a string of digits) or an IMVU name (looked up: linked accounts first, then IMVU, rate-limited; unknown: 404 `no_such_imvu_account`). Replaces the list; duplicates and the owner's account are dropped. More than 20: 400 `invalid`. Making a room private sends no one out. |
|
||||
| `GET /api/v1/hosted-rooms` | public rooms, and private ones the person owns or moderates. `?mine=1`: the person's own (private too). `?favorites=1`: their favorites, latest starred first. `?recent=1`: the last 20 distinct rooms they joined (any of their accounts), latest first. Those three need a signed-in person (401 `signed_out`); adult rooms only for adults, as before; closed and hidden rooms left out. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | anyone may get a private room (it's unlisted, not secret). Also `myRating`: the person's own rating, `0` if none or signed out. |
|
||||
| `GET /api/v1/hosted-rooms/:n/participants` | (anyone who may `GET /:n`, also signed out; same 404/410) `{participants: [{imvu_id, seat, name}]}`, as in `room.participants` |
|
||||
| `GET /api/v1/hosted-rooms/:n/bans` | (owner or moderator) `{bans: [{imvuId, byImvuId, until, createdAt}]}`: bans in force, latest first; `until` ISO time or `null` (for good). Others: 403 `forbidden`. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/bans/:imvu` | (owner only) lifts a ban: 204; 404 if there's none. Logged (`hosted_room.unban`). |
|
||||
| `PUT /api/v1/hosted-rooms/:n/image` | (owner) multipart, field `image`: a PNG or JPEG (by its magic bytes, whatever the declared type), up to 512 KB; else 400 `invalid`. Stored as uploaded. → `{room}`. 30 an hour. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/image` | (owner) → `{room}` with `image: null` |
|
||||
| `GET /api/v1/hosted-rooms/:n/image` | (anyone who may `GET /:n`) the image, with its `Content-Type`, `ETag` (`If-None-Match` answers 304), `Cache-Control: public, max-age=3600` (`private` for adult rooms), `X-Content-Type-Options: nosniff`; 404 if none |
|
||||
| `PUT /api/v1/hosted-rooms/:n/favorite` | (signed in, a room they may see) stars it: 204, also if starred already |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/favorite` | 204 |
|
||||
| `PUT /api/v1/hosted-rooms/:n/rating` | `{score}` (integer 1-5) → `{room, myRating}`; replaces their earlier rating. Owners can't rate their own room (403 `forbidden`). |
|
||||
| `DELETE /api/v1/hosted-rooms/:n/rating` | removes their rating: 204 |
|
||||
|
||||
A visit is recorded when an IMVU account joins a hosted room (a new member; not a re-join of a session already in
|
||||
it): per person, the room, the account and the time; the latest 50 per person are kept.
|
||||
|
||||
### Realtime
|
||||
|
||||
**Participants** everywhere (`room.joined`, `room.participants`, `room.occupancy`, `GET .../participants`) are
|
||||
`{"imvu_id": X, "seat": k, "name": "..."}`; `name` is the IMVU username of the hub's verified link for that account
|
||||
(as of when it joined), `""` if unknown.
|
||||
|
||||
**Joining** (`room.join`) may carry `"invite": id`. New `room.error` reasons, checked in this order after phase 1's
|
||||
`not_linked`, `not_found`, `closed`, `adult`: `"banned"` (a ban in force on the session's account), then `"private"`
|
||||
(a private room, and the account isn't its owner account, a moderator, already let in, or holding an accepted,
|
||||
unexpired invite for this room and account), then `too_many`, `full`.
|
||||
|
||||
An invite used to join is spent, and the account is **let in**: it may join again without one (a reconnect, another
|
||||
session) until it sends `room.leave` and none of its sessions is still in the room, or it's booted. (A connection
|
||||
dropping doesn't end it. It's kept in memory: a hub restart ends it.)
|
||||
|
||||
**Boot and ban**, from the owner account or a moderator (needn't be in the room):
|
||||
|
||||
- proxy → hub `{"type": "room.boot", "room": n, "imvu_id": X, "ban"?: false, "minutes"?: 0}`. `ban` true stores a
|
||||
ban; `minutes` 0 (default) is for good, else 1 to 5,270,400 (about ten years). Malformed: `{"type": "error",
|
||||
"message": "bad imvu_id" | "bad ban" | "bad minutes" | "bad room"}`.
|
||||
- hub → proxy (the sender) `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": true, "ban": bool}`, or
|
||||
`{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": false, "reason": "...", "message": "..."}` with
|
||||
`reason` `"not_linked"`, `"not_found"`, `"closed"` or `"forbidden"`: the sender isn't the owner account or a
|
||||
moderator; X is the owner account (nobody boots the owner); X is a moderator and the sender isn't the owner
|
||||
account; or X is the sender.
|
||||
- Every session of X in the room gets `{"type": "room.closed", "room": n, "reason": "booted"}` and is out; the others
|
||||
get `room.participants`; watchers `room.occupancy`. A boot without a ban doesn't stop X coming back (except into
|
||||
a private room, which needs a new invite).
|
||||
- Logged: `hosted_room.boot` or `hosted_room.ban`, target the room number, detail `{imvu: X, by: sender's IMVU ID}`
|
||||
(and `minutes` for a ban), actor the sender's person. A banned moderator stays a moderator (the owner removes them
|
||||
from `moderators`).
|
||||
|
||||
**Invitations**:
|
||||
|
||||
- proxy → hub `{"type": "room.invite", "room": n, "imvu_id": X}`: from a session in the room (public rooms), or from
|
||||
the owner account or a moderator, in it or not (private rooms). 10, then 10 a minute per session.
|
||||
- hub → proxy (the sender) `{"type": "room.invite_result", "room": n, "imvu_id": X, "ok": true, "invite": id}`, or
|
||||
`ok: false` with `reason` `"not_linked"`, `"not_found"`, `"closed"`, `"forbidden"` (private room, not owner or
|
||||
moderator), `"not_in_room"` (public room, the sender isn't in it), `"banned"` (X is banned from the room),
|
||||
`"adult"` (an adult room, and none of X's sessions counts as adult) or `"rate_limited"`, and `message`. X being the
|
||||
sender: `{"type": "error", "message": "bad imvu_id"}`.
|
||||
- When `ok`: every session whose IMVU account is X gets `{"type": "room.invited", "room": n, "invite": id, "from":
|
||||
sender's IMVU ID, "room_name": "...", "owner_name": "..."}` (`owner_name`: the owner account's IMVU name). If X has
|
||||
no session, the sender instead gets `{"type": "room.invite_answer", "invite": id, "to": X, "accept": false,
|
||||
"offline": true}` right after the result, and the invite is dropped.
|
||||
- proxy (X's) → hub `{"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."}`. The first answer
|
||||
counts. `note`: control characters become spaces, trimmed, up to 200 characters, left out when empty. Errors:
|
||||
`{"type": "error", "message": "no such invite" | "already answered" | "bad accept" | "bad note"}` (`no such invite`
|
||||
also when it expired or the session's account isn't X).
|
||||
- hub → proxy (every session of the sender's IMVU account) `{"type": "room.invite_answer", "invite": id, "to": X,
|
||||
"accept": bool, "note"?: "..."}`.
|
||||
- An invite: single use, valid 10 minutes from when it was made (accepting doesn't extend it), for that room and
|
||||
account. Kept in memory only; closing the room drops its invites. `invite` ids are opaque strings (base64url).
|
||||
|
||||
**Occupancy**:
|
||||
|
||||
- proxy → hub `{"type": "room.watch", "rooms": [n, ...]}`: replaces the session's watched set (up to 100; `[]`
|
||||
stops). Not a list of room numbers, or over 100: `{"type": "error", "message": "bad rooms"}`. Rooms the session
|
||||
may not see are left out silently: no such room, closed or hidden; adult rooms for a session whose account isn't
|
||||
adult; private rooms unless its account is the owner account or a moderator. That's checked when it watches.
|
||||
- hub → proxy, once per watched room right after, then whenever the room's participants change (join, leave, seat,
|
||||
boot): `{"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}`. A room closing sends
|
||||
`participants: []` and stops watching it. A session closing stops its watching.
|
||||
|
||||
### The proxy's part (phase 2)
|
||||
|
||||
- Room cards and lists: `privacy`, `image`, `score`/`ratings` from the room views; people in a room by name from
|
||||
`participants` (`name`), or `GET .../participants` for a room it isn't in or watching.
|
||||
- `room_occupancy/<owner>-<2100000000+n>` queues: from `room.watch`/`room.occupancy` instead of polling.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Products, catalogs and the library: the contract
|
||||
|
||||
How the hub (registry), catalogs (`t5de-catalog`, file hosting) and T5DE's proxy work together. The design and the
|
||||
reasons are in `design.md` ("Products and catalogs", "Revisions", "In the client's shop and inventory"); this is
|
||||
what each side sends.
|
||||
|
||||
## Words
|
||||
|
||||
- **Product**: a T5 product, owned by its creator (a hub person). ID: 10 characters of `a-z2-7` (random), e.g.
|
||||
`k3mq7xa2pd`. Valid as a pack ID, so the proxy can store a downloaded product as a pack.
|
||||
- **Revision**: a product's files, numbered 1, 2, 3... Never changes. Its **file list** is
|
||||
`[{name, sha256, size}]`, sorted by name; `sha256` is lowercase hex.
|
||||
- **Catalog**: a server storing files by sha256 and serving them. Registered on the hub; hidden until a hub
|
||||
moderator approves it.
|
||||
- **Library**: the products a person "got" (free), per person (all their IMVU accounts), optionally staying on a
|
||||
revision.
|
||||
|
||||
## Tokens
|
||||
|
||||
The hub signs tokens with its own Ed25519 key (EdDSA JWTs, compact form), published as a JWKS at
|
||||
`GET /.well-known/t5de-keys.json` (`kid` in each token's header). Catalogs fetch it (and again when they see an
|
||||
unknown `kid`). Claims always include `iss` (the hub's API URL), `typ` (below) and `iat`.
|
||||
|
||||
| `typ` | Other claims | Lifetime | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `t5de-upload` | `sub` (person ID), `catalog` (catalog ID), `product` (optional: a new revision of this product) | 30 min | the creator's browser uploads to the catalog |
|
||||
| `t5de-access` | `sub` (person ID), `adult` (bool) | 1 h | the proxy fetches adult files from catalogs |
|
||||
| `t5de-revision` | `product`, `revision`, `rating`, `files` (the file list), `parent` (IMVU product ID or 0) | none (`exp` absent) | the hub vouches for a revision's files |
|
||||
|
||||
## Catalog API (`t5de-catalog`)
|
||||
|
||||
Served at the catalog's public URL. CORS: the hub website's origin may call `POST /api/v1/uploads`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /health` | `{ok: true}` |
|
||||
| `POST /api/v1/uploads` | `Authorization: Bearer <t5de-upload token>`; multipart form: `file` (the product as a zip: Create Mode's `.chkn`, files at the root) and `notes` (text, required for a new revision of an existing product, optional for the first). The catalog validates the files (t5de-api `pack` rules: names, limits, `index.xml` with `ProductRating` GA/AP), stores each file by sha256, then registers the revision with the hub (below) and answers the hub's answer: 201 `{product, revision}`. Errors: `{error, message}`, 400 for bad files, 401 for a bad token, 502 if the hub refused or failed. |
|
||||
| `GET /api/v1/files/<sha256>` | the file's bytes, `Cache-Control: public, max-age=31536000, immutable` and `ETag` = the hash. A file used only by adult revisions needs `Authorization: Bearer <t5de-access token>` with `adult: true` (else 401/403, and `Cache-Control: private`). 404 if the catalog doesn't have it. |
|
||||
| `GET /api/v1/revisions/<product>/<revision>` | `{product, revision, rating, files}`, for revisions the catalog hosts |
|
||||
|
||||
Configuration (environment): `CATALOG_ADDR` (listen address, default `127.0.0.1:4102`), `CATALOG_DATA` (data folder:
|
||||
SQLite database and files), `CATALOG_PUBLIC_URL`, `HUB_URL` (the hub's API), `CATALOG_ID` and `CATALOG_KEY` (from
|
||||
registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
|
||||
|
||||
## Hub API
|
||||
|
||||
### For catalogs (server to server)
|
||||
|
||||
`Authorization: Catalog <catalog ID>:<key>`. Only approved catalogs.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision}`. The upload token must be for this catalog. Without `product` in the token, a new product is made (owner: the token's person, name from `name`). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). |
|
||||
|
||||
### For people (website, signed in; the proxy with its bearer token)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/catalogs` | register a catalog: `{name, url, contact, visibility ("public"/"private"), membership ("open"/"approval"/"invite")}` → 201 `{catalog, key}` (the key is shown once) |
|
||||
| `GET /api/v1/catalogs` | (anyone) approved public catalogs; the person's own, any status |
|
||||
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL |
|
||||
| `GET /api/v1/products?q=&creator=&category=&limit=` | (anyone) `{products}`, newest first; adult ones only for people with an adult account. `category`: an IMVU category ID; only products whose latest revision's parent has it anywhere in its category path (e.g. 106 Clothing, 128 Tops). `limit`: 1-200, default 60 |
|
||||
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
|
||||
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
|
||||
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
|
||||
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after. 204 |
|
||||
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
|
||||
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
|
||||
| `PUT /api/v1/library/:id` | get a product (add it) |
|
||||
| `DELETE /api/v1/library/:id` | remove it |
|
||||
| `PATCH /api/v1/library/:id` | `{stayOn: n or null}` |
|
||||
| `GET /api/v1/products/:id/files` | for the proxy: `{product, revision, rating, parent, name, files, catalogs: [url], token}` for the revision this person uses (their pin, else the latest that isn't retracted); `token` is the `t5de-revision` token |
|
||||
| `POST /api/v1/access` | for the proxy: `{token}`, a `t5de-access` token (`adult` if any of the person's IMVU accounts counts as adult) |
|
||||
|
||||
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes.
|
||||
|
||||
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
|
||||
parent, image, categories, createdAt, updatedAt}`. `parent`, `image` and `categories` are the latest revision's:
|
||||
`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and
|
||||
`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if
|
||||
unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`).
|
||||
|
||||
### Parent info
|
||||
|
||||
The hub looks up a revision's parent when the revision is registered (after it's stored; an upload never waits for or
|
||||
fails on it) and stores it with the revision (`product_revision.parent_info`: `{found, image, categories, gender,
|
||||
checkedAt}`; `found` is false when IMVU shows no such product publicly). Info another revision got for the same
|
||||
parent in the last 7 days is reused. Revisions still without it (IMVU was unavailable) are looked up at startup and
|
||||
every 10 minutes, 20 at a time.
|
||||
|
||||
### Console (moderators)
|
||||
|
||||
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
|
||||
|
||||
## The proxy
|
||||
|
||||
- Fetches the library at connect and on `library` messages; for each product, `GET /api/v1/products/:id/files`,
|
||||
downloads missing files from a listed catalog (`GET <catalog>/api/v1/files/<sha256>`, with an access token for
|
||||
adult revisions), checks each sha256, and writes the product as a pack in its cache
|
||||
(`%APPDATA%\T5DE\proxy\hub-products\<id>\`: `t5de-product.json` with the hub's name and rating, and `files/`).
|
||||
From there it's served like any pack.
|
||||
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client.
|
||||
- Library products appear in the client's inventory (see the proxy's README).
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU
|
||||
keeps the room without them; the hub keeps them (`room_decoration`, routes in api.md), and only T5DE users see them.
|
||||
|
||||
- **Saving**: when the room's owner saves the room (`test.setRoomState`) or the client broadcasts a furniture change
|
||||
in it, the proxy takes the hub products out before it goes to IMVU, and `PUT /api/v1/decorations/<instance>` the
|
||||
room's full list of them (`[furniture instance ID, hub product ID, placement]`).
|
||||
- **Loading**: when the client loads an IMVU room (`test.getRoomState`), the proxy `GET`s its decorations and adds
|
||||
them to the room's contents (as local IDs, fetching the products as for outfits); the `hash` tells it whether
|
||||
what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back
|
||||
the same way.
|
||||
- **Changes**: after a `PUT`, every proxy with a chat in that room gets `{"type": "decor", "room": instance}` on the
|
||||
realtime connection and `GET`s again.
|
||||
- Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for
|
||||
adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room.
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE "age_check" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"imvu_id" bigint,
|
||||
"provider" text NOT NULL,
|
||||
"method" text DEFAULT '' NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"over_eighteen" boolean DEFAULT false NOT NULL,
|
||||
"provider_ref" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"checked_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "age_check" ADD CONSTRAINT "age_check_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "age_check_user" ON "age_check" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "age_check_imvu" ON "age_check" USING btree ("imvu_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "age_check_provider_ref" ON "age_check" USING btree ("provider","provider_ref");--> statement-breakpoint
|
||||
-- Existing links' IMVU results become IMVU age checks (expiring at once, so they're checked again soon).
|
||||
INSERT INTO "age_check" ("id", "user_id", "imvu_id", "provider", "method", "status", "over_eighteen", "checked_at", "expires_at")
|
||||
SELECT md5(random()::text || "imvu_id"::text), "user_id", "imvu_id", 'imvu',
|
||||
CASE WHEN "age_verified" THEN 'imvu_age_verified' WHEN "access_pass" THEN 'imvu_access_pass' ELSE 'imvu_none' END,
|
||||
'complete', "age_verified" OR "access_pass", "checked_at", now()
|
||||
FROM "imvu_link";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "age_verified";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "access_pass";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "checked_at";
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "room" (
|
||||
"instance_id" text PRIMARY KEY NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"image_url" text DEFAULT '' NOT NULL,
|
||||
"privacy" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"content_rating" text DEFAULT '' NOT NULL,
|
||||
"access_pass" boolean DEFAULT false NOT NULL,
|
||||
"occupancy" integer DEFAULT 0 NOT NULL,
|
||||
"capacity" integer DEFAULT 0 NOT NULL,
|
||||
"expired" boolean DEFAULT false NOT NULL,
|
||||
"checked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"seen_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" ADD COLUMN "list_rooms" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "room_owner" ON "room" USING btree ("owner_imvu_id");
|
||||
@@ -0,0 +1,72 @@
|
||||
CREATE TABLE "catalog" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"contact" text NOT NULL,
|
||||
"visibility" text DEFAULT 'public' NOT NULL,
|
||||
"membership" text DEFAULT 'open' NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosting" (
|
||||
"catalog_id" text NOT NULL,
|
||||
"product_id" text NOT NULL,
|
||||
"number" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosting_catalog_id_product_id_number_pk" PRIMARY KEY("catalog_id","product_id","number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "library" (
|
||||
"user_id" text NOT NULL,
|
||||
"product_id" text NOT NULL,
|
||||
"stay_on" integer,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "library_user_id_product_id_pk" PRIMARY KEY("user_id","product_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "product" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"creator_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"rating" text NOT NULL,
|
||||
"latest" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "product_revision" (
|
||||
"product_id" text NOT NULL,
|
||||
"number" integer NOT NULL,
|
||||
"files" jsonb NOT NULL,
|
||||
"rating" text NOT NULL,
|
||||
"parent" bigint DEFAULT 0 NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"uploaded_by" text,
|
||||
"retracted" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "product_revision_product_id_number_pk" PRIMARY KEY("product_id","number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "signing_key" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"public_key" jsonb NOT NULL,
|
||||
"private_key" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD CONSTRAINT "catalog_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosting" ADD CONSTRAINT "hosting_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library" ADD CONSTRAINT "library_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library" ADD CONSTRAINT "library_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD CONSTRAINT "product_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "catalog_url" ON "catalog" USING btree ("url");--> statement-breakpoint
|
||||
CREATE INDEX "hosting_product" ON "hosting" USING btree ("product_id");--> statement-breakpoint
|
||||
CREATE INDEX "product_creator" ON "product" USING btree ("creator_id");--> statement-breakpoint
|
||||
CREATE INDEX "product_created" ON "product" USING btree ("created_at");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "hosted_room" (
|
||||
"n" serial PRIMARY KEY NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"room_pid" bigint NOT NULL,
|
||||
"rating" text DEFAULT 'general' NOT NULL,
|
||||
"max_users" integer DEFAULT 10 NOT NULL,
|
||||
"scene" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"scene_hash" text DEFAULT '' NOT NULL,
|
||||
"closed" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD CONSTRAINT "hosted_room_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_owner" ON "hosted_room" USING btree ("owner_id");--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_created" ON "hosted_room" USING btree ("created_at");
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "room_decoration" (
|
||||
"instance_id" text PRIMARY KEY NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"contents" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id");
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "hosted_room_ban" (
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"by_imvu_id" bigint NOT NULL,
|
||||
"until" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_favorite" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_image" (
|
||||
"n" integer PRIMARY KEY NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"data" "bytea" NOT NULL,
|
||||
"hash" text NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_rating" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"score" integer NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_visit" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
|
||||
@@ -0,0 +1,999 @@
|
||||
{
|
||||
"id": "6311568f-0471-47a9-8199-229d365d3dc4",
|
||||
"prevId": "47a7aacf-4e5f-4181-9ac1-0100ba416d0c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user": {
|
||||
"name": "account_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.age_check": {
|
||||
"name": "age_check",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imvu_id": {
|
||||
"name": "imvu_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"over_eighteen": {
|
||||
"name": "over_eighteen",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"provider_ref": {
|
||||
"name": "provider_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"checked_at": {
|
||||
"name": "checked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"age_check_user": {
|
||||
"name": "age_check_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"age_check_imvu": {
|
||||
"name": "age_check_imvu",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "imvu_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"age_check_provider_ref": {
|
||||
"name": "age_check_provider_ref",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "provider_ref",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"age_check_user_id_user_id_fk": {
|
||||
"name": "age_check_user_id_user_id_fk",
|
||||
"tableFrom": "age_check",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit": {
|
||||
"name": "audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"at": {
|
||||
"name": "at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target": {
|
||||
"name": "target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_at": {
|
||||
"name": "audit_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"audit_actor_id_user_id_fk": {
|
||||
"name": "audit_actor_id_user_id_fk",
|
||||
"tableFrom": "audit",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"actor_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.device_code": {
|
||||
"name": "device_code",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"device_code": {
|
||||
"name": "device_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_code": {
|
||||
"name": "user_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_polled_at": {
|
||||
"name": "last_polled_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"polling_interval": {
|
||||
"name": "polling_interval",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"device_code_user_id_user_id_fk": {
|
||||
"name": "device_code_user_id_user_id_fk",
|
||||
"tableFrom": "device_code",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.imvu_link": {
|
||||
"name": "imvu_link",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"imvu_id": {
|
||||
"name": "imvu_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pairwise_id": {
|
||||
"name": "pairwise_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"linked_at": {
|
||||
"name": "linked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"show_badge": {
|
||||
"name": "show_badge",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"show_presence": {
|
||||
"name": "show_presence",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"imvu_link_user": {
|
||||
"name": "imvu_link_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"imvu_link_user_id_user_id_fk": {
|
||||
"name": "imvu_link_user_id_user_id_fk",
|
||||
"tableFrom": "imvu_link",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"imvu_link_pairwise_id_unique": {
|
||||
"name": "imvu_link_pairwise_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"pairwise_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.jwks": {
|
||||
"name": "jwks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"private_key": {
|
||||
"name": "private_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"alg": {
|
||||
"name": "alg",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"crv": {
|
||||
"name": "crv",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.link_challenge": {
|
||||
"name": "link_challenge",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imvu_id": {
|
||||
"name": "imvu_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"link_challenge_user_id_user_id_fk": {
|
||||
"name": "link_challenge_user_id_user_id_fk",
|
||||
"tableFrom": "link_challenge",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"link_challenge_user_id_imvu_id_pk": {
|
||||
"name": "link_challenge_user_id_imvu_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"imvu_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user": {
|
||||
"name": "session_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_username": {
|
||||
"name": "display_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'user'"
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"link_cap": {
|
||||
"name": "link_cap",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_username_lower": {
|
||||
"name": "user_username_lower",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "lower(\"username\")",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier": {
|
||||
"name": "verification_identifier",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,62 @@
|
||||
"when": 1793273462704,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1793277687785,
|
||||
"tag": "0001_age_checks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1793321456968,
|
||||
"tag": "0002_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1793324955738,
|
||||
"tag": "0003_products",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1793347080470,
|
||||
"tag": "0004_parent_info",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1793355138984,
|
||||
"tag": "0005_hosted_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1793357692630,
|
||||
"tag": "0006_decorations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1793364727935,
|
||||
"tag": "0007_hosted_rooms_phase2",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1793372872749,
|
||||
"tag": "0008_product_deletion",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
+303
-3
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -137,15 +138,63 @@ export const imvuLink = pgTable(
|
||||
pairwiseId: text("pairwise_id").notNull().unique(),
|
||||
username: text("username").notNull(),
|
||||
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ageVerified: boolean("age_verified").notNull().default(false),
|
||||
accessPass: boolean("access_pass").notNull().default(false),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
showBadge: boolean("show_badge").notNull().default(false),
|
||||
showPresence: boolean("show_presence").notNull().default(false),
|
||||
listRooms: boolean("list_rooms").notNull().default(false),
|
||||
},
|
||||
(t) => [index("imvu_link_user").on(t.userId)],
|
||||
);
|
||||
|
||||
// An IMVU room a T5DE user entered whose owner is a linked account, as IMVU last described it. Listed on the hub
|
||||
// only if its owner chose to list their rooms and IMVU says it's public and listed.
|
||||
export const room = pgTable(
|
||||
"room",
|
||||
{
|
||||
instanceId: text("instance_id").primaryKey(), // "<owner>-<n>"
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
imageURL: text("image_url").notNull().default(""),
|
||||
privacy: text("privacy").notNull(),
|
||||
type: text("type").notNull(),
|
||||
contentRating: text("content_rating").notNull().default(""),
|
||||
accessPass: boolean("access_pass").notNull().default(false),
|
||||
occupancy: integer("occupancy").notNull().default(0),
|
||||
capacity: integer("capacity").notNull().default(0),
|
||||
expired: boolean("expired").notNull().default(false),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
seenAt: timestamp("seen_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("room_owner").on(t.ownerImvuId)],
|
||||
);
|
||||
|
||||
// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a
|
||||
// birth date, document, photo or name (providers that see those keep them).
|
||||
export const ageCheck = pgTable(
|
||||
"age_check",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person.
|
||||
imvuId: bigint("imvu_id", { mode: "number" }),
|
||||
provider: text("provider").notNull(), // "imvu", "didit", "wallet", ...
|
||||
method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc"
|
||||
status: text("status").notNull(), // "pending", "complete", "failed", "revoked"
|
||||
overEighteen: boolean("over_eighteen").notNull().default(false),
|
||||
providerRef: text("provider_ref"), // the provider's session or transaction ID
|
||||
createdAt: createdAt(),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
index("age_check_user").on(t.userId),
|
||||
index("age_check_imvu").on(t.imvuId),
|
||||
uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef),
|
||||
],
|
||||
);
|
||||
|
||||
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
|
||||
export const linkChallenge = pgTable(
|
||||
"link_challenge",
|
||||
@@ -173,3 +222,254 @@ export const audit = pgTable(
|
||||
},
|
||||
(t) => [index("audit_at").on(t.at)],
|
||||
);
|
||||
|
||||
// ---- Products (docs/products.md) ----
|
||||
|
||||
export type FileEntry = { name: string; sha256: string; size: number };
|
||||
|
||||
/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */
|
||||
export type ParentInfo = {
|
||||
found: boolean; // false: IMVU shows no such product publicly
|
||||
image: string | null; // https, on *.imvu.com
|
||||
categories: { id: number; name: string }[]; // outermost first
|
||||
gender: string; // "female", "male" or ""
|
||||
checkedAt: string; // ISO time
|
||||
};
|
||||
|
||||
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
|
||||
export const signingKey = pgTable("signing_key", {
|
||||
id: text("id").primaryKey(), // the JWK's kid
|
||||
publicKey: jsonb("public_key").$type<Record<string, string>>().notNull(),
|
||||
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
// A catalog: a server hosting product files. Hidden until a moderator approves it.
|
||||
export const catalog = pgTable(
|
||||
"catalog",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(), // its public URL
|
||||
contact: text("contact").notNull(), // takedown and legal contact
|
||||
visibility: text("visibility").notNull().default("public"), // "public", "private"
|
||||
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
|
||||
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("catalog_url").on(t.url)],
|
||||
);
|
||||
|
||||
// A product: its info, owned by its creator. Its files are in revisions.
|
||||
export const product = pgTable(
|
||||
"product",
|
||||
{
|
||||
id: text("id").primaryKey(), // 10 of a-z2-7
|
||||
creatorId: text("creator_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
||||
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
|
||||
removedAt: timestamp("removed_at", { withTimezone: true }),
|
||||
removedReason: text("removed_reason"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
// A revision: a product's files. Never changes (but can be retracted).
|
||||
export const productRevision = pgTable(
|
||||
"product_revision",
|
||||
{
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
number: integer("number").notNull(),
|
||||
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
||||
rating: text("rating").notNull(),
|
||||
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
||||
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
|
||||
notes: text("notes").notNull().default(""),
|
||||
token: text("token").notNull(), // the hub's t5de-revision token
|
||||
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
||||
retracted: boolean("retracted").notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.productId, t.number] })],
|
||||
);
|
||||
|
||||
// Which catalogs host which revisions.
|
||||
export const hosting = pgTable(
|
||||
"hosting",
|
||||
{
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
productId: text("product_id").notNull(),
|
||||
number: integer("number").notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)],
|
||||
);
|
||||
|
||||
// A person's library: products they got, optionally staying on a revision.
|
||||
export const library = pgTable(
|
||||
"library",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
stayOn: integer("stay_on"),
|
||||
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
||||
);
|
||||
|
||||
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
|
||||
|
||||
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
|
||||
export type SceneItem = [number, number | string, string];
|
||||
|
||||
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
|
||||
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
|
||||
export const hostedRoom = pgTable(
|
||||
"hosted_room",
|
||||
{
|
||||
n: serial("n").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""), // plain text
|
||||
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
|
||||
rating: text("rating").notNull().default("general"), // "general", "adult"
|
||||
maxUsers: integer("max_users").notNull().default(10),
|
||||
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
|
||||
sceneHash: text("scene_hash").notNull().default(""),
|
||||
closed: boolean("closed").notNull().default(false),
|
||||
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
|
||||
privacy: text("privacy").notNull().default("public"),
|
||||
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
|
||||
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
|
||||
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
|
||||
imageHash: text("image_hash"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
|
||||
export type RoomModerator = { imvuId: number; username: string };
|
||||
|
||||
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
|
||||
dataType: () => "bytea",
|
||||
fromDriver: (v) => Buffer.from(v),
|
||||
});
|
||||
|
||||
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
|
||||
export const hostedRoomImage = pgTable("hosted_room_image", {
|
||||
n: integer("n")
|
||||
.primaryKey()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
|
||||
data: bytea("data").notNull(),
|
||||
hash: text("hash").notNull(), // sha256, hex
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
// IMVU accounts banned from a hosted room; until null: for good.
|
||||
export const hostedRoomBan = pgTable(
|
||||
"hosted_room_ban",
|
||||
{
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
|
||||
until: timestamp("until", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
|
||||
);
|
||||
|
||||
// A person's favorite hosted rooms.
|
||||
export const hostedRoomFavorite = pgTable(
|
||||
"hosted_room_favorite",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] })],
|
||||
);
|
||||
|
||||
// A person's rating of a hosted room, 1-5.
|
||||
export const hostedRoomRating = pgTable(
|
||||
"hosted_room_rating",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
score: integer("score").notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
|
||||
);
|
||||
|
||||
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
|
||||
export const hostedRoomVisit = pgTable(
|
||||
"hosted_room_visit",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
|
||||
);
|
||||
|
||||
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
|
||||
|
||||
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
|
||||
export type DecorationItem = [number, string, string];
|
||||
|
||||
// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from
|
||||
// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in
|
||||
// hosted_room.scene instead.
|
||||
export const roomDecoration = pgTable(
|
||||
"room_decoration",
|
||||
{
|
||||
instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "<owner>-<n>"
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
|
||||
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
|
||||
);
|
||||
Reference in new issue
Block a user