Compare commits

...
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 c75a4c9afc feat: age verification with an ID document (Didit), and closed registration
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 58s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
Age: a Didit provider behind the vendor-neutral age interface. A person
verifies once (ID document, passive liveness, face match) and all their
linked IMVU accounts count as adult. Webhooks are checked against Didit's
signatures; only "over 18" is kept. A later answer replaces an earlier one
(an approval taken back is revoked). Checks are limited: one unfinished
check per person, 3 in 30 days, none once verified, and a monthly cap
(DIDIT_MONTHLY_LIMIT, default 30: Didit's free plan is $10 of credit a
month and a check costs $0.30). The Account page gets an Age Verification
card.

Registration: open or closed, an admin's setting in the console
(default from REGISTRATION). Closed: new accounts can only sign in until
a moderator approves them (or declines, deleting the account); the
website shows them a waiting page and the API treats them as signed out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:15:47 -08:00
release-bot 86ebe976ba chore(release): v0.2.0 (#3)
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m10s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 4s
Merging this releases **v0.2.0**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.

## 0.2.0 (2026-11-03)

### Features

- **web:** Download link to t5de.dev in the header (#2)

Merging this releases **v0.2.0**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.

## 0.2.0 (2026-11-03)

### Features

- **web:** Download link to t5de.dev in the header (#2)

Co-authored-by: release-bot <[email protected]>
2026-11-03 11:29:26 +00:00
dhkatz efd5d52198 feat(web): Download link to t5de.dev in the header (#2)
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m2s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 12s
A Download link in the header, beside the account menu or Sign in, to t5de.dev/download: T5DE's website with the latest setup, its version and checksum. Gitea's release pages are confusing for people new to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

A Download link in the header, beside the account menu or Sign in, to t5de.dev/download: T5DE's website with the latest setup, its version and checksum. Gitea's release pages are confusing for people new to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-03 10:54:20 +00:00
dhkatz f8ab3f965e ci: release pull requests, and conventional-commit PR titles (#1)
Release / release (push) Successful in 2s
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
Releases as pull requests, the same in all five app repos (hub, catalog, live, riftbound, karaoke). The Gitea side (PRs only into main, squash merges, required checks, `v*` tags only by release-bot, its `RELEASE_TOKEN` org secret) is dhkatz/infra `modules/app-repo`.

- **`.gitea/workflows/pull-request.yml`**: the PR title must be a conventional commit (`feat(api): …`, `fix: …`, `feat!: …`); it becomes the commit on main. `chore(release)` only from release-bot's `release` branch. Required to merge (`Pull request / title`).
- **`.gitea/default_merge_message/SQUASH_TEMPLATE.md`**: the squash commit is `<PR title> (#<n>)` plus the description.
- **`.gitea/workflows/release.yml`**, on every push to main, as release-bot (`RELEASE_TOKEN`):
  - if the commit is a merged `chore(release): vX.Y.Z`: tag it vX.Y.Z (lightweight), push the tag (CI builds the image, Komodo deploys prod), publish the release with git-cliff's notes;
  - otherwise, if anything releasable landed since the last tag: rewrite the `release` branch (CHANGELOG.md) and open or update `chore(release): vX.Y.Z`.
- **`cliff.toml`** (git-cliff 2.14.2, pinned by sha512): feat → minor, fix/perf/revert → patch, breaking → minor while 0.x; chore/ci/docs/test/style/build/refactor alone don't release. Only conventional commits count, so the history before this is left out.
- **`CHANGELOG.md`**: header only; release PRs fill it.
- Agent notes (CLAUDE.md / AGENTS.md): the PR flow and title format; never tag by hand.

After merging, `Release` runs on main and should report nothing to release (this PR is `ci:`); the first `feat:`/`fix:` PR merged after it opens the first release PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #1
Co-authored-by: dhkatz <[email protected]>
2026-11-03 08:40:29 +00:00
dhkatzandClaude Opus 5.5 21e1bd8790 A slim Staging bar on staging
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
Shown above the header when the site is served from a staging. address, or with HUB_ENVIRONMENT=staging
(production hides it), read per request since staging and production run the same image.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:18:26 -07:00
dhkatzandClaude Opus 5.5 76c8e0342e Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:07:41 -07:00
dhkatzandClaude Opus 5.5 43183ec7ee Hosted rooms' instance IDs name their owner: <owner>-<2100000000+n>
CI / test (push) Successful in 58s
The client takes a room's owner from the instance ID's first part; with 2100000000 there, the owner
couldn't decorate their hosted room (putting furniture on went to their home room instead).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:06:08 -07:00
94 changed files with 18585 additions and 245 deletions

No files matched your search

@@ -0,0 +1,3 @@
${PullRequestTitle} (#${PullRequestIndex})
${PullRequestDescription}
+37 -1
View File
@@ -1,9 +1,11 @@
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub. # Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub; then, for main and vX.Y.Z
# tags, builds its images and pushes them to git.t5de.dev. Komodo deploys them (dhkatz/infra); CI never deploys.
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -32,3 +34,37 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
# main → :latest-main and :main-<commit>; vX.Y.Z → :X.Y.Z, :X.Y and :latest. Built by the runner's Docker.
image:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { image: t5de-hub-api, dockerfile: apps/api/Dockerfile }
- { image: t5de-hub-web, dockerfile: apps/web/Dockerfile }
steps:
- uses: actions/checkout@v4
- name: Build and push
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
IMAGE: git.t5de.dev/t5de/${{ matrix.image }}
DOCKERFILE: ${{ matrix.dockerfile }}
run: |
case "$GITHUB_REF" in
refs/heads/main) version="main-${GITHUB_SHA::12}"; tags="latest-main $version" ;;
refs/tags/v*)
version="${GITHUB_REF#refs/tags/v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Not a vX.Y.Z tag: no image"; exit 0; }
tags="$version ${version%.*} latest" ;;
*) echo "Not main or a tag: no image"; exit 0 ;;
esac
echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u deploy --password-stdin
docker build -f "$DOCKERFILE" --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label org.opencontainers.image.revision="$GITHUB_SHA" --label org.opencontainers.image.version="$version" \
$(for t in $tags; do echo "-t $IMAGE:$t"; done) .
for t in $tags; do docker push -q "$IMAGE:$t"; done
docker logout git.t5de.dev
+30
View File
@@ -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"
+86
View File
@@ -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
+44
View File
@@ -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
+9 -1
View File
@@ -10,6 +10,13 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
in our own words are fine. in our own words are fine.
- Test with a throwaway IMVU account, never a real one. - Test with a throwaway IMVU account, never a real one.
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email. - Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
- 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. - IMVU session keys never leave the user's machine; the hub never asks for them.
## Notes ## Notes
@@ -26,4 +33,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
(`t5de-proxy...`). (`t5de-proxy...`).
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no - On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
`bunx`: use `bun x`). `bunx`: use `bun x`).
- Calls to IMVU (`apps/api/src/imvu.ts`, its public user API) are rare and rate-limited: linking and age re-checks. - Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms,
and the IMVU products T5 products derive from (`parent_info`, looked up once per revision).
+11 -2
View File
@@ -8,15 +8,21 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
## What works ## What works
- **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is - **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
an admin; roles user, moderator, admin. an admin; roles user, moderator, admin. Registration open or closed (new accounts wait for a moderator's
approval): an admin's setting in the console, defaulting to the API's `REGISTRATION`.
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public - **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
(default 10). (default 10).
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub. - **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub, or
if its person verified their age with an ID document and a selfie (Didit, when configured: `AGE_PROVIDERS`, see
`apps/api/src/index.ts` and docs/design.md, "Age").
- **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the - **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge. use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge.
- **Products** (website): browsing and search, product pages, uploads straight from the browser to a catalog,
editing, revisions (retract, stay on one), the library, and registering catalogs (`docs/products.md`). Pages send
a strict Content Security Policy (`apps/web/proxy.ts`).
- **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log. - **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log.
## Layout ## Layout
@@ -46,4 +52,7 @@ Needs Bun (1.4) and Node (for Next.js).
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
public address) and `WEB_URL` (the website's). public address) and `WEB_URL` (the website's).
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`. T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
+3 -1
View File
@@ -5,7 +5,9 @@ COPY package.json bun.lock ./
COPY apps/api/package.json apps/api/ COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile --production --filter @t5de/api # Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated
# layout keeps links in each package's own node_modules.
RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted
FROM oven/bun:1.4.4-slim FROM oven/bun:1.4.4-slim
WORKDIR /app WORKDIR /app
+123
View File
@@ -0,0 +1,123 @@
// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in
// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether
// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest.
//
// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session
// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AgeResult, Provider } from "./index";
export type DiditConfig = {
apiKey: string;
workflowId: string;
/** The webhook destination's secret_shared_key. */
webhookSecret: string;
/** Checks started a month at most (what the month's credit pays for). */
monthlyLimit?: number;
baseURL?: string;
fetch?: typeof fetch;
};
/** How far a webhook's X-Timestamp may be from now. */
const timestampTolerance = 300;
/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */
const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]);
type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] };
/** Age in whole years on a date, from an ISO birth date. */
export function ageOn(birth: string, today: Date): number | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth);
if (!m) return undefined;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
let age = today.getUTCFullYear() - y;
if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--;
return age;
}
/** The age on the session's ID document, or undefined if it has none. */
function documentAge(decision: Decision | undefined): number | undefined {
const id = decision?.id_verifications?.[0];
if (typeof id?.age === "number") return id.age;
return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined;
}
/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */
function canonical(v: unknown): unknown {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === "object")
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
return v;
}
function hmacMatches(secret: string, input: string, signature: string | null): boolean {
if (!signature) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex"));
const given = Buffer.from(signature.trim().toLowerCase());
return expected.length === given.length && timingSafeEqual(expected, given);
}
export function diditAgeProvider(config: DiditConfig): Provider {
const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, "");
const fetcher = config.fetch ?? fetch;
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetcher(base + path, {
method,
headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`);
return res.json();
};
return {
id: "didit",
label: "ID document and selfie",
vendor: "Didit",
scope: "person",
monthlyLimit: config.monthlyLimit,
async start(subject, { returnURL }) {
const s = (await call("POST", "/v3/session/", {
workflow_id: config.workflowId,
vendor_data: subject.userId,
callback: returnURL,
})) as { session_id: string; url: string };
return { kind: "redirect", url: s.url, ref: s.session_id };
},
async webhook(request) {
const raw = await request.text();
const timestamp = Number(request.headers.get("x-timestamp"));
if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook");
const body = JSON.parse(raw) as {
webhook_type?: string;
session_id?: string;
status?: string;
decision?: Decision;
};
// The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header
// during a rotation.
const v2 = JSON.stringify(canonical(body));
const signed = ["", "-previous"].some(
(suffix) =>
hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) ||
hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)),
);
if (!signed) throw new Error("bad signature");
if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null;
if (!body.session_id || !body.status) return null;
const ref = body.session_id;
if (failedStatuses.has(body.status)) return { ref, result: "failed" };
if (body.status !== "Approved") return null;
const decision =
body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision);
const age = documentAge(decision);
if (age === undefined) return { ref, result: "failed" };
const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref };
return { ref, result };
},
};
}
+1
View File
@@ -19,6 +19,7 @@ export function imvuAgeProvider(imvu: ImvuClient): Provider {
return { return {
id: "imvu", id: "imvu",
label: "IMVU's age verification or Access Pass", label: "IMVU's age verification or Access Pass",
vendor: "IMVU",
scope: "imvu_account", scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }), start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check, recheck: check,
+80 -17
View File
@@ -1,7 +1,7 @@
// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface, // 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. // results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
// See docs/design.md, "Age". // See docs/design.md, "Age".
import { and, eq, gt, inArray, isNull, lte, or, schema, type Database } from "@t5de/db"; import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
const { ageCheck } = schema; const { ageCheck } = schema;
@@ -27,13 +27,19 @@ export type Started =
export type Provider = { export type Provider = {
id: string; id: string;
/** Shown to people. */ /** Shown to people: the way of checking, and who does it. */
label: string; label: string;
vendor: string;
/** Whether it checks a person, or an IMVU account. */ /** Whether it checks a person, or an IMVU account. */
scope: "person" | "imvu_account"; scope: "person" | "imvu_account";
/** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */
monthlyLimit?: number;
start(subject: Subject, options: { returnURL: string }): Promise<Started>; 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" }>; * A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws
* if the request isn't really from the provider.
*/
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>;
/** Finishes a check the browser completed (wallets): the browser's response for ref. */ /** Finishes a check the browser completed (wallets): the browser's response for ref. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">; complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */ /** Checks a subject again, for answers that can change. */
@@ -41,6 +47,12 @@ export type Provider = {
}; };
export class AgeError extends Error {} export class AgeError extends Error {}
/** A check that can't be started now; the message is for the person. */
export class AgeRefused extends Error {}
/** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */
export const attemptsPerPerson = 3;
const attemptWindow = 30 * 24 * 60 * 60 * 1000;
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url"); const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
@@ -56,7 +68,7 @@ export class AgeService {
/** The providers in use, for the website. */ /** The providers in use, for the website. */
list() { list() {
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope })); return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope }));
} }
provider(id: string): Provider { provider(id: string): Provider {
@@ -87,13 +99,25 @@ export class AgeService {
await this.db.insert(ageCheck).values({ id: randomID(), ...values }); 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. */ /**
* Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose
* check is unfinished goes back to it (providers give the same session again); new checks of a person are
* limited, per person and by the provider's monthly allowance.
*/
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> { async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
const p = this.provider(providerId); const p = this.provider(providerId);
const ofPerson = subject.imvuId === undefined;
const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined;
if (ofPerson) {
if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified.");
if (!pending) await this.checkLimits(subject.userId, p);
}
const started = await p.start(subject, { returnURL }); const started = await p.start(subject, { returnURL });
if (started.kind === "done") { if (started.kind === "done") {
await this.record(subject, p.id, started.result); await this.record(subject, p.id, started.result);
} else { } else if (pending?.providerRef !== started.ref) {
// An unfinished check the provider replaced with a new one (it expired there) won't be answered.
if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id));
await this.db.insert(ageCheck).values({ await this.db.insert(ageCheck).values({
id: randomID(), id: randomID(),
userId: subject.userId, userId: subject.userId,
@@ -106,11 +130,55 @@ export class AgeService {
return started; return started;
} }
/** Finishes a pending check (from a webhook or the browser). */ private async pendingCheck(userId: string, provider: string) {
async finish(providerId: string, ref: string, result: AgeResult | "failed") { const [row] = await this.db
.select()
.from(ageCheck)
.where(
and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")),
);
return row;
}
private async personAdult(userId: string) {
const now = new Date();
return (await this.personChecks(userId)).some(
(c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now),
);
}
private async checkLimits(userId: string, p: Provider) {
const started = async (...where: Parameters<typeof and>) =>
(await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n;
const since = new Date(Date.now() - attemptWindow);
if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson)
throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`);
if (p.monthlyLimit !== undefined) {
const now = new Date();
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit)
throw new AgeRefused("This month's ID checks are used up. Try again next month.");
}
}
/**
* Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also
* one already answered, since the provider can change its answer (say, after a manual review). An adult answer
* taken back is revoked.
*/
async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) {
const [row] = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref)));
if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check");
const done = const done =
result === "failed" result === "failed"
? { status: "failed", overEighteen: false, checkedAt: new Date() } ? {
status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed",
overEighteen: false,
checkedAt: new Date(),
}
: { : {
status: "complete", status: "complete",
overEighteen: result.overEighteen, overEighteen: result.overEighteen,
@@ -118,13 +186,8 @@ export class AgeService {
checkedAt: new Date(), checkedAt: new Date(),
expiresAt: result.expiresAt ?? null, expiresAt: result.expiresAt ?? null,
}; };
const rows = await this.db const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
.update(ageCheck) return updated!;
.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). */ /** Removes an IMVU account's checks (it was unlinked or moved to another person). */
+664 -28
View File
@@ -2,15 +2,45 @@
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer // origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
// token. // token.
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db"; import {
and,
asc,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
count,
schema,
type Database,
type ParentInfo,
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth"; import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age"; import { AgeError, AgeRefused, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu"; import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime"; import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms"; import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys"; import { Signer } from "./keys";
import { ProductError, Products } from "./products"; import { ProductError, Products } from "./products";
import {
chatId,
HostedError,
HostedRooms,
instanceId,
maxImageBytes,
maxModerators,
maxPlacement,
maxSceneItems,
roomInfo,
type RoomChanges,
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
const { user, session, imvuLink, linkChallenge, audit } = schema; const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -23,6 +53,10 @@ export type Deps = {
rooms?: Rooms; rooms?: Rooms;
/** The hub's API URL, the issuer of its tokens. */ /** The hub's API URL, the issuer of its tokens. */
apiURL?: string; apiURL?: string;
/** The website's address: providers send people back only to it. */
webURL?: string;
/** The hub's settings (default: kept in the database, with no environment defaults). */
settings?: Settings;
}; };
/** An error shown to the person: the website displays message as is. */ /** An error shown to the person: the website displays message as is. */
@@ -73,11 +107,25 @@ class Limiter {
} }
} }
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) { export function createApp({
db,
auth,
imvu,
realtime: rt,
age: ageService,
rooms: roomsService,
apiURL,
webURL,
settings: hubSettings,
}: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer); const products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, imvu); const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const decorations = new Decorations(db);
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
const realtime = const realtime =
rt ?? rt ??
new Realtime({ new Realtime({
@@ -89,6 +137,23 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
room: (instanceId) => { room: (instanceId) => {
rooms.seen(instanceId).catch((e) => console.error("rooms:", e)); rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
}, },
hostedRoom: (n) => hosted.info(n),
hostedBanned: (n, imvuId) => hosted.banned(n, imvuId),
hostedBoot: async (b) => {
if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes);
await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), {
imvu: b.imvuId,
by: b.by,
...(b.ban ? { minutes: b.minutes } : {}),
});
},
accountName: async (imvuId) => {
const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.username ?? "";
},
hostedVisit: (userId, n, imvuId) => {
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
},
}); });
const limiter = new Limiter(); const limiter = new Limiter();
@@ -101,8 +166,33 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
banned: p.banned === true, banned: p.banned === true,
banReason: p.banReason ?? "", banReason: p.banReason ?? "",
approved: p.approved,
}); });
/** A person's picture, for the website's header: their first linked IMVU account's avatar picture (null if none,
* or IMVU can't say). Kept an hour per account, so pages don't each ask IMVU. */
const pictures = new Map<number, { url: string | null; at: number }>();
const picture = async (userId: string): Promise<string | null> => {
const [first] = await db
.select({ imvuId: imvuLink.imvuId })
.from(imvuLink)
.where(eq(imvuLink.userId, userId))
.orderBy(asc(imvuLink.linkedAt))
.limit(1);
if (!first) return null;
const kept = pictures.get(first.imvuId);
if (kept && Date.now() - kept.at < 60 * 60 * 1000) return kept.url;
let url: string | null = kept?.url ?? null;
try {
url = (await imvu.byId(first.imvuId)).picture;
} catch {
// IMVU unavailable: the last known one (or none), asked again next time.
return url;
}
pictures.set(first.imvuId, { url, at: Date.now() });
return url;
};
/** Links as the website shows them: with live presence, and age by the one rule. */ /** Links as the website shows them: with live presence, and age by the one rule. */
const linkViews = async (userId: string, links: Link[]) => { const linkViews = async (userId: string, links: Link[]) => {
const ids = links.map((l) => l.imvuId); const ids = links.map((l) => l.imvuId);
@@ -178,10 +268,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
} }
}; };
/** `latest`: the latest revision's parent and its info (the image and categories are the parent's). */
const productView = ( const productView = (
p: typeof schema.product.$inferSelect, p: typeof schema.product.$inferSelect,
creator?: string | null, creator?: string | null,
creatorUsername?: string | null, creatorUsername?: string | null,
latest?: { parent: number | null; parentInfo: ParentInfo | null } | null,
) => ({ ) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
@@ -190,6 +282,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
latest: p.latest, latest: p.latest,
creator: creator ?? creatorUsername ?? "", creator: creator ?? creatorUsername ?? "",
creatorUsername: creatorUsername ?? "", creatorUsername: creatorUsername ?? "",
parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null,
categories: latest?.parentInfo?.categories ?? [],
deleted: p.deletedAt !== null,
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(), updatedAt: p.updatedAt.toISOString(),
}); });
@@ -205,6 +301,126 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
}); });
/** Runs a hosted-room operation, turning its errors into Problems. */
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof HostedError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string };
type Extras = { favorite: boolean; score: number; ratings: number };
const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({
n: r.n,
instanceId: instanceId(r.ownerImvuId, r.n),
chatId: chatId(r.n),
name: r.name,
description: r.description,
roomPid: r.roomPid,
rating: r.rating as "general" | "adult",
maxUsers: r.maxUsers,
ownerImvuId: r.ownerImvuId,
ownerName,
occupancy: realtime.occupancy(r.n),
privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private",
moderators: r.moderators.map((m) => m.imvuId),
moderatorNames: r.moderators.map((m) => m.username),
image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null,
favorite: extras.favorite,
score: extras.score,
ratings: extras.ratings,
createdAt: r.createdAt.toISOString(),
});
/** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */
const hostedViews = async (rows: HostedRow[], person: Person | undefined) => {
const ns = rows.map((r) => r.room.n);
const [scores, favorites] = await Promise.all([
hosted.scores(ns),
person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set<number>()),
]);
return rows.map((r) =>
hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }),
);
};
const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!;
/** The person and their linked IMVU accounts. */
const viewerOf = async (p: Person): Promise<Viewer> => ({
id: p.id,
imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id),
});
/** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */
const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => {
if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`);
const out: RoomModerator[] = [];
for (const e of entries) {
const text = typeof e === "string" ? e.trim() : "";
const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0;
if (id > 0) {
const name = known.find((m) => m.imvuId === id)?.username;
const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id));
out.push({ imvuId: id, username: name ?? l?.username ?? "" });
continue;
}
if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs.");
const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase());
if (k) {
out.push(k);
continue;
}
const [l] = await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username })
.from(imvuLink)
.where(sql`lower(${imvuLink.username}) = lower(${text})`);
if (l) {
out.push(l);
continue;
}
try {
const found = await imvuCall(me, () => imvu.byUsername(text));
out.push({ imvuId: found.id, username: found.username });
} catch (err) {
if (err instanceof Problem && err.slug === "no_such_imvu_account")
throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`);
throw err;
}
}
return out;
};
const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) });
const rating = t.Union([t.Literal("general"), t.Literal("adult")]);
const privacy = t.Union([t.Literal("public"), t.Literal("private")]);
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
const sceneItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ pattern: "^[a-z2-7]{10}$" })]),
t.String({ maxLength: maxPlacement }),
]);
/** Runs a decorations operation, turning its errors into Problems. */
const decorationCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof DecorationError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
const decorationItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.String({ pattern: "^[a-z2-7]{10}$" }),
t.String({ maxLength: maxDecorationPlacement }),
]);
const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) }); const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const ownLink = async (p: Person, imvuId: number) => { const ownLink = async (p: Person, imvuId: number) => {
@@ -250,17 +466,33 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.derive(async ({ request }) => { .derive(async ({ request }) => {
const s = await auth.api.getSession({ headers: request.headers }); const s = await auth.api.getSession({ headers: request.headers });
const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined; const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined;
return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id }; // A person waiting for approval counts as signed out everywhere but their own account (/me).
const usable = person && !person.banned ? person : undefined;
return {
person: usable?.approved ? usable : undefined,
waiting: usable && !usable.approved ? usable : undefined,
sessionId: s?.session.id,
};
}) })
.macro({ .macro({
/** Signed in, approved or still waiting. */
account: {
resolve: ({ person, waiting }) => {
const me = person ?? waiting;
if (!me) throw new Problem(401, "signed_out", "Sign in first.");
return { me };
},
},
signedIn: { signedIn: {
resolve: ({ person }) => { resolve: ({ person, waiting }) => {
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person }; return { me: person };
}, },
}, },
moderator: { moderator: {
resolve: ({ person }) => { resolve: ({ person, waiting }) => {
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!person) throw new Problem(401, "signed_out", "Sign in first.");
if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this."); if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
return { me: person }; return { me: person };
@@ -269,7 +501,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}) })
// Who's signed in, and the home page. // Who's signed in, and the home page.
.get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true }) .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true })
// The hub's public settings: whether new accounts wait for approval.
.get("/hub", async () => ({ registration: await settings.registration() }))
.get("/home", async ({ person }) => { .get("/home", async ({ person }) => {
const online = (await visibleOnline(person)).length; const online = (await visibleOnline(person)).length;
if (!person) return { onlineCount: online }; if (!person) return { onlineCount: online };
@@ -560,6 +794,292 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
return { rooms: out }; return { rooms: out };
}) })
// T5DE-hosted rooms (docs/hosted-rooms.md).
.post(
"/hosted-rooms",
async ({ me, body, set }) => {
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
await audited(me.id, "hosted_room.create", String(r.room.n), {
name: r.room.name,
rating: r.room.rating,
privacy: r.room.privacy,
});
set.status = 201;
return { room: hostedView(r) };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 64 }),
description: t.Optional(t.String({ maxLength: 512 })),
roomPid,
rating,
maxUsers: t.Optional(maxUsers),
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
privacy: t.Optional(privacy),
}),
},
)
.get(
"/hosted-rooms",
async ({ person, query }) => {
const on = (v?: string) => v === "1" || v === "true";
const mine = on(query.mine);
const favorites = on(query.favorites);
const recent = on(query.recent);
if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({
adult: await personAdult(person),
viewer: person ? await viewerOf(person) : undefined,
mine,
favorites,
recent,
});
return { rooms: await hostedViews(rows, person) };
},
{
query: t.Object({
mine: t.Optional(t.String({ maxLength: 8 })),
favorites: t.Optional(t.String({ maxLength: 8 })),
recent: t.Optional(t.String({ maxLength: 8 })),
}),
},
)
.get(
"/hosted-rooms/:n",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return {
room: await hostedViewFor(r, person),
scene: { contents: r.room.scene, hash: r.room.sceneHash },
myRating: person ? await hosted.myRating(person.id, r.room.n) : 0,
};
},
{ params: hostedParam },
)
.patch(
"/hosted-rooms/:n",
async ({ me, params, body }) => {
const { moderators: given, ...rest } = body;
const changes: RoomChanges = given
? {
...rest,
moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators),
}
: rest;
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
realtime.hostedChanged(r.room.n, roomInfo(r));
await audited(me.id, "hosted_room.edit", String(r.room.n), {
fields: Object.keys(body),
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
});
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 512 })),
rating: t.Optional(rating),
maxUsers: t.Optional(maxUsers),
roomPid: t.Optional(roomPid),
privacy: t.Optional(privacy),
moderators: t.Optional(
t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), {
maxItems: maxModerators,
}),
),
}),
},
)
.put(
"/hosted-rooms/:n/scene",
async ({ me, params, body }) => {
if (!limiter.allow(`scene:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
const r = await hostedCall(() => hosted.setScene(me.id, params.n, body.contents, body.roomPid));
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ contents: t.Array(sceneItem, { maxItems: maxSceneItems }), roomPid: t.Optional(roomPid) }),
},
)
.delete(
"/hosted-rooms/:n",
async ({ me, params, set }) => {
const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n));
realtime.hostedClosed(r.n);
await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Phase 2: bans, images, favorites, ratings.
.get(
"/hosted-rooms/:n/participants",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { participants: realtime.participants(r.room.n) };
},
{ params: hostedParam },
)
.get(
"/hosted-rooms/:n/bans",
async ({ me, params }) => {
await hostedCall(async () => hosted.managed(await viewerOf(me), params.n));
const bans = await hosted.bans(params.n);
return {
bans: bans.map((b) => ({
imvuId: b.imvuId,
byImvuId: b.byImvuId,
until: b.until?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
})),
};
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/bans/:imvu",
async ({ me, params, set }) => {
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
throw new Problem(404, "not_found", "That account isn't banned here.");
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
set.status = 204;
},
{
signedIn: true,
params: t.Object({
n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }),
imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }),
}),
},
)
.get(
"/hosted-rooms/:n/image",
async ({ person, params, request }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
const img = r.room.imageHash ? await hosted.image(params.n) : undefined;
if (!img) throw new Problem(404, "not_found", "This room has no image.");
const etag = `"${img.hash}"`;
const headers = {
"Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`,
ETag: etag,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
};
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } });
},
{ params: hostedParam },
)
.put(
"/hosted-rooms/:n/image",
async ({ me, params, body }) => {
if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while.");
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
const data = Buffer.from(await body.image.arrayBuffer());
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ image: t.File() }),
beforeHandle({ request }) {
// Refuse big uploads before reading them (with room for the multipart framing).
if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024)
throw new Problem(400, "invalid", "An image is up to 512 KB.");
},
},
)
.delete(
"/hosted-rooms/:n/image",
async ({ me, params }) => {
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
return { room: await hostedViewFor(r, me) };
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
await hosted.favorite(me.id, params.n, true);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
await hosted.favorite(me.id, params.n, false);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/rating",
async ({ me, params, body }) => {
if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room.");
await hosted.rate(me.id, params.n, body.score);
return { room: await hostedViewFor(r, me), myRating: body.score };
},
{ signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) },
)
.delete(
"/hosted-rooms/:n/rating",
async ({ me, params, set }) => {
await hosted.rate(me.id, params.n, null);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Decorations over IMVU rooms (docs/products.md, "Decorations").
.get(
"/decorations",
async ({ person, query }) => {
const instances = [...new Set((query.instances ?? "").split(",").filter(Boolean))];
if (instances.length > maxBatch) throw new Problem(400, "invalid", `Up to ${maxBatch} rooms at a time.`);
return { rooms: await decorationCall(async () => decorations.get(instances, await personAdult(person))) };
},
{ query: t.Object({ instances: t.Optional(t.String({ maxLength: 1024 })) }) },
)
.get(
"/decorations/:instance",
async ({ person, params }) =>
(await decorationCall(async () => decorations.get([params.instance], await personAdult(person))))[0]!,
{ params: t.Object({ instance: t.String({ maxLength: 32 }) }) },
)
.put(
"/decorations/:instance",
async ({ me, params, body }) => {
if (!limiter.allow(`decor:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
await decorationCall(() => decorations.set(me.id, params.instance, body.contents));
realtime.decorated(params.instance);
return (await decorations.get([params.instance], await personAdult(me)))[0]!;
},
{
signedIn: true,
params: t.Object({ instance: t.String({ maxLength: 32 }) }),
body: t.Object({ contents: t.Array(decorationItem, { maxItems: maxDecorations }) }),
},
)
// Catalogs: registering one, and the directory. // Catalogs: registering one, and the directory.
.post( .post(
"/catalogs", "/catalogs",
@@ -616,10 +1136,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
"/products", "/products",
async ({ person, query }) => { async ({ person, query }) => {
const adult = await personAdult(person); const adult = await personAdult(person);
const rows = await products.list({ q: query.q, creator: query.creator, adult }); const rows = await products.list({ ...query, adult });
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) }; return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) };
},
{
query: t.Object({
q: t.Optional(t.String({ maxLength: 100 })),
creator: t.Optional(t.String({ maxLength: 64 })),
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
}, },
{ query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })) }) },
) )
.get( .get(
"/products/:id", "/products/:id",
@@ -627,8 +1154,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const adult = await personAdult(person); const adult = await personAdult(person);
const p = await productCall(() => products.get(params.id, adult)); const p = await productCall(() => products.get(params.id, adult));
const lib = person ? await products.inLibrary(person.id, params.id) : undefined; const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
const latest = p.revisions.find((r) => r.number === p.product.latest);
return { return {
product: productView(p.product, p.creator, p.creatorUsername), product: productView(p.product, p.creator, p.creatorUsername, latest),
revisions: p.revisions.map((r) => ({ revisions: p.revisions.map((r) => ({
number: r.number, number: r.number,
rating: r.rating, rating: r.rating,
@@ -651,7 +1179,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
async ({ me, params, body }) => { async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body)); const p = await productCall(() => products.update(me.id, params.id, body));
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) }); await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
return { product: productView(p) }; return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
}, },
{ {
signedIn: true, signedIn: true,
@@ -663,6 +1191,26 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}), }),
}, },
) )
// Its creator deletes a product (no longer listed or added; those who have it keep it).
.delete(
"/products/:id",
async ({ me, params, set }) => {
await productCall(() => products.delete(me.id, params.id));
await audited(me.id, "product.delete", params.id);
set.status = 204;
},
{ signedIn: true, params: productParam },
)
// Moderation removes a product: gone everywhere.
.post(
"/products/:id/remove",
async ({ me, params, body }) => {
await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() });
return { ok: true };
},
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
.post( .post(
"/products/:id/revisions/:n/retract", "/products/:id/revisions/:n/retract",
async ({ me, params }) => { async ({ me, params }) => {
@@ -704,7 +1252,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
"/library", "/library",
async ({ me }) => ({ async ({ me }) => ({
library: (await products.library(me.id)).map((l) => ({ library: (await products.library(me.id)).map((l) => ({
product: productView(l.product), product: productView(l.product, l.creator, undefined, l),
stayOn: l.stayOn, stayOn: l.stayOn,
addedAt: l.addedAt.toISOString(), addedAt: l.addedAt.toISOString(),
})), })),
@@ -770,7 +1318,23 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
throw new Problem(404, "not_found", "That way of verifying age isn't available."); throw new Problem(404, "not_found", "That way of verifying age isn't available.");
} }
if (p.scope !== "person") throw new Problem(400, "invalid", "That one checks an IMVU account: link it instead."); if (p.scope !== "person") throw new Problem(400, "invalid", "That one checks an IMVU account: link it instead.");
const started = await age.start({ userId: me.id }, p.id, body.returnURL); // A path on the website, or an address on it.
const web = new URL(webURL ?? "http://localhost:4100");
let back: URL | undefined;
try {
back = new URL(body.returnURL, web);
} catch {}
if (!back || back.origin !== web.origin)
throw new Problem(400, "invalid", "The return address must be on the hub's website.");
let started;
try {
started = await age.start({ userId: me.id }, p.id, back.href);
} catch (e) {
if (e instanceof AgeRefused) throw new Problem(409, "refused", e.message);
console.error(`age ${p.id}:`, e);
throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment.");
}
await audited(me.id, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref });
return started.kind === "done" ? { kind: "done" as const } : started; return started.kind === "done" ? { kind: "done" as const } : started;
}, },
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) }, { signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) },
@@ -804,13 +1368,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
let answer; let answer;
try { try {
answer = await p.webhook(request); answer = await p.webhook(request);
} catch { } catch (e) {
// Not from the provider, or its answer couldn't be read: either way the provider tries again later.
console.error(`age webhook ${p.id}:`, e instanceof Error ? e.message : e);
throw new Problem(401, "forbidden", "Not from the provider."); throw new Problem(401, "forbidden", "Not from the provider.");
} }
try { if (answer) {
await age.finish(p.id, answer.ref, answer.result); try {
} catch (e) { await age.finish(p.id, answer.ref, answer.result, { anyStatus: true });
if (!(e instanceof AgeError)) throw e; // already finished: webhooks can repeat } catch (e) {
if (!(e instanceof AgeError)) throw e; // a check that isn't the hub's (another environment's)
}
} }
set.status = 204; set.status = 204;
}, },
@@ -840,6 +1408,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const [counts] = await db const [counts] = await db
.select({ .select({
people: sql<number>`(select count(*) from ${user})::int`, people: sql<number>`(select count(*) from ${user})::int`,
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`,
links: sql<number>`(select count(*) from ${imvuLink})::int`, links: sql<number>`(select count(*) from ${imvuLink})::int`,
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
}) })
@@ -868,18 +1437,61 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.select() .select()
.from(user) .from(user)
.where( .where(
q and(
? or( q
ilike(user.username, like), ? or(
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))), ilike(user.username, like),
) inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
: undefined, )
: undefined,
query.waiting ? and(eq(user.approved, false), sql`coalesce(${user.banned}, false) = false`) : undefined,
),
) )
.orderBy(desc(user.createdAt)) .orderBy(desc(user.createdAt))
.limit(100); .limit(100);
return { people: rows.map(personView) }; return { people: rows.map(personView) };
}, },
{ moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) }, {
moderator: true,
query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })), waiting: t.Optional(t.BooleanString()) }),
},
)
// Accounts waiting for approval (registration closed): approve, or decline (the account is deleted).
.post(
"/console/people/:id/approve",
async ({ me, params }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning();
if (!p.approved) await audited(me.id, "person.approve", p.username ?? p.id);
return { person: personView(updated!) };
},
{ moderator: true },
)
.post(
"/console/people/:id/decline",
async ({ me, params }) => {
const p = await personById(params.id);
if (p.approved) throw new Problem(400, "invalid", "Only accounts waiting for approval can be declined.");
await db.delete(user).where(eq(user.id, p.id));
await audited(me.id, "person.decline", p.username ?? p.id);
return { declined: true };
},
{ moderator: true },
)
.get("/console/settings", async () => ({ registration: await settings.registration() }), { moderator: true })
.put(
"/console/settings",
async ({ me, body }) => {
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings.");
const from = await settings.registration();
await settings.setRegistration(body.registration);
if (from !== body.registration) await audited(me.id, "hub.registration", body.registration, { from });
return { registration: body.registration };
},
{
moderator: true,
body: t.Object({ registration: t.Union([t.Literal("open"), t.Literal("closed")]) }),
},
) )
.get( .get(
"/console/people/:id", "/console/people/:id",
@@ -944,6 +1556,30 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}, },
) )
// T5DE's proxy: which of the IMVU accounts it's showing get the T5 badge (linked, the badge turned on, the
// person not banned). Nothing else about them.
.post(
"/badges",
async ({ me, body }) => {
if (!limiter.allow(`badges:${me.id}`, 120, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while.");
const ids = [...new Set(body.imvuIds)];
if (ids.length === 0) return { badges: [] };
const rows = await db
.select({ imvuId: imvuLink.imvuId })
.from(imvuLink)
.innerJoin(user, eq(user.id, imvuLink.userId))
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showBadge, true), sql`coalesce(${user.banned}, false) = false`));
return { badges: rows.map((r) => r.imvuId) };
},
{
signedIn: true,
body: t.Object({
imvuIds: t.Array(t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), { maxItems: 200 }),
}),
},
)
// T5DE's proxy: the live connection. // T5DE's proxy: the live connection.
.ws("/realtime", { .ws("/realtime", {
async beforeHandle({ person, set }) { async beforeHandle({ person, set }) {
@@ -978,7 +1614,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.get("/health", () => ({ ok: true })) .get("/health", () => ({ ok: true }))
.use(api); .use(api);
return { app, realtime, age, rooms, products, signer }; return { app, realtime, age, rooms, products, signer, hosted, decorations };
} }
export type App = ReturnType<typeof createApp>["app"]; export type App = ReturnType<typeof createApp>["app"];
+9 -2
View File
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins"; import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db"; import { count, schema, type Database } from "@t5de/db";
import { Settings } from "./settings";
export type AuthConfig = { export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */ /** The API's own address, e.g. https://hub-api.t5de.dev. */
@@ -15,12 +16,15 @@ export type AuthConfig = {
secret: string; secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */ /** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean; rateLimit?: boolean;
/** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */
settings?: Settings;
}; };
/** The proxy's client ID in device sign-in. */ /** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy"; export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) { export function createAuth(db: Database, config: AuthConfig) {
const settings = config.settings ?? new Settings(db);
return betterAuth({ return betterAuth({
baseURL: config.apiURL, baseURL: config.apiURL,
basePath: "/api/auth", basePath: "/api/auth",
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
user: { user: {
additionalFields: { additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false }, linkCap: { type: "number", defaultValue: 10, input: false },
approved: { type: "boolean", defaultValue: true, input: false },
}, },
}, },
// Proxies stay signed in for months; a session is extended when it's used. // Proxies stay signed in for months; a session is extended when it's used.
@@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) {
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
// The first account runs the hub. // The first account runs the hub; with registration closed, the others wait for approval.
before: async (u) => { before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user); const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } }; const first = (row?.n ?? 0) === 0;
const approved = first || (await settings.registration()) === "open";
return { data: { ...u, role: first ? "admin" : "user", approved } };
}, },
}, },
}, },
+109
View File
@@ -0,0 +1,109 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
export const maxDecorations = 300;
export const maxDecorationPlacement = 200;
export const maxBatch = 20;
/** An IMVU room instance: "<owner's customer ID>-<n>". */
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
/** An error for the person; the routes turn it into a Problem. */
export class DecorationError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const decorationHash = (contents: DecorationItem[]) =>
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
function ownerOf(instance: string): number {
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
const owner = Number(instance.slice(0, instance.indexOf("-")));
if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase)
throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
return owner;
}
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
export class Decorations {
constructor(private readonly db: Database) {}
/**
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
* retracted, and general unless the viewer counts as adult.
*/
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: product.id })
.from(product)
.where(
and(
inArray(product.id, ids),
ne(product.latest, 0),
isNull(product.removedAt),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
);
return new Set(rows.map((r) => r.id));
}
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
for (const i of instances) ownerOf(i);
const rows = instances.length
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
: [];
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
return instances.map((instance) => {
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
return { instance, contents, hash: decorationHash(contents) };
});
}
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
const owner = ownerOf(instance);
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
if (contents.length > maxDecorations)
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
if (new Set(contents.map((it) => it[0])).size !== contents.length)
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
const ids = [...new Set(contents.map((it) => it[1]))];
if (ids.length) {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
return;
}
await this.db
.insert(roomDecoration)
.values({ instanceId: instance, ownerImvuId: owner, contents })
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
}
}
+437
View File
@@ -0,0 +1,437 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): made on the website, joined through T5DE's proxy. The routes are in
// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves, and (phase 2) their moderators, bans,
// images, and people's favorites, ratings and visits.
//
// A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they
// aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused.
import { createHash } from "node:crypto";
import {
and,
avg,
count,
desc,
eq,
gt,
inArray,
isNull,
notInArray,
or,
sql,
schema,
type Database,
type RoomModerator,
type SceneItem,
} from "@t5de/db";
import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
const { hostedRoom, hostedRoomBan, hostedRoomFavorite, hostedRoomImage, hostedRoomRating, hostedRoomVisit, imvuLink, user } =
schema;
export type HostedRoom = typeof hostedRoom.$inferSelect;
export type Rating = "general" | "adult";
export type Privacy = "public" | "private";
/** An error for the person; the routes turn it into a Problem. */
export class HostedError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const maxRoomsPerPerson = 10;
export const maxSceneItems = 500;
export const maxPlacement = 200;
export const maxModerators = 20;
export const maxImageBytes = 512 * 1024;
const visitsKept = 50;
const recentShown = 20;
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
// "<owner's IMVU ID>-<hostedBase + n>": the client takes a room's owner from the first part (so the owner can
// decorate it), and IMVU's own room numbers are far below hostedBase.
export const instanceId = (owner: number, n: number) => `${owner}-${hostedBase + n}`;
export const chatId = (n: number) => hostedBase + n;
// eslint-disable-next-line no-control-regex
const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
function cleanName(s: string): string {
const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim();
if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters.");
return v;
}
function cleanDescription(s: string): string {
const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim();
if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters.");
return v;
}
/** An image's content type by its magic bytes: PNG or JPEG only. */
export function imageType(b: Uint8Array): "image/png" | "image/jpeg" | undefined {
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (b.length > 24 && png.every((v, i) => b[i] === v)) return "image/png";
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
return undefined;
}
export type NewRoom = {
name: string;
description?: string;
roomPid: number;
rating: Rating;
maxUsers?: number;
imvuId: number;
privacy?: Privacy;
};
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers" | "privacy">> & {
/** Already resolved to accounts (app.ts resolves names). */
moderators?: RoomModerator[];
};
type Row = { room: HostedRoom; ownerName: string };
/** Who's looking: the person and their linked IMVU accounts (for private rooms they moderate). */
export type Viewer = { id: string; imvuIds: number[] };
export const isRoomModerator = (r: HostedRoom, imvuIds: Iterable<number>) => {
const ids = new Set(imvuIds);
return r.moderators.some((m) => ids.has(m.imvuId));
};
export class HostedRooms {
constructor(private readonly db: Database) {}
// Rooms that are shown: the owner account still linked to the owner, the owner not banned.
private shown() {
return this.db
.select({ room: hostedRoom, ownerName: imvuLink.username })
.from(hostedRoom)
.innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId)))
.innerJoin(user, eq(user.id, hostedRoom.ownerId))
.$dynamic();
}
private visible = sql`coalesce(${user.banned}, false) = false`;
/** Public rooms, and private ones the viewer owns or moderates. */
private listable(viewer?: Viewer) {
if (!viewer) return eq(hostedRoom.privacy, "public");
const mods = viewer.imvuIds.length
? sql`exists (select 1 from jsonb_array_elements(${hostedRoom.moderators}) m where (m->>'imvuId')::bigint in (${sql.join(
viewer.imvuIds.map((id) => sql`${id}`),
sql`, `,
)}))`
: undefined;
return or(eq(hostedRoom.privacy, "public"), eq(hostedRoom.ownerId, viewer.id), mods);
}
/**
* Open rooms the viewer may see: listed ones (public, and private ones they own or moderate), newest first; or
* (mine) their own; (favorites) their favorites, latest first; (recent) the last 20 they joined, latest first.
*/
async list({
adult,
viewer,
mine,
favorites,
recent,
}: {
adult: boolean;
viewer?: Viewer;
mine?: boolean;
favorites?: boolean;
recent?: boolean;
}): Promise<Row[]> {
const open = and(this.visible, eq(hostedRoom.closed, false), adult || mine ? undefined : eq(hostedRoom.rating, "general"));
if (viewer && favorites) {
return this.shown()
.innerJoin(hostedRoomFavorite, and(eq(hostedRoomFavorite.n, hostedRoom.n), eq(hostedRoomFavorite.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomFavorite.createdAt), desc(hostedRoom.n))
.limit(200);
}
if (viewer && recent) {
return this.shown()
.innerJoin(hostedRoomVisit, and(eq(hostedRoomVisit.n, hostedRoom.n), eq(hostedRoomVisit.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomVisit.at))
.limit(recentShown);
}
return this.shown()
.where(and(open, mine && viewer ? eq(hostedRoom.ownerId, viewer.id) : this.listable(viewer)))
.orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n))
.limit(200);
}
/** A room as shown to a viewer; closed rooms are 410, hidden and adult ones (for others) 404. */
async get(n: number, adult: boolean, viewerId?: string): Promise<Row> {
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
if (!r) throw new HostedError(404, "not_found", "No such room.");
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
throw new HostedError(404, "not_found", "No such room.");
return r;
}
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
async info(n: number): Promise<HostedRoomInfo | undefined> {
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
return r ? roomInfo(r) : undefined;
}
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
const name = cleanName(body.name);
const description = cleanDescription(body.description ?? "");
if (body.rating === "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId)));
if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you.");
return this.db.transaction(async (tx) => {
const [{ n } = { n: 0 }] = await tx
.select({ n: count() })
.from(hostedRoom)
.where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false)));
if (n >= maxRoomsPerPerson)
throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`);
const [r] = await tx
.insert(hostedRoom)
.values({
ownerId,
ownerImvuId: link.imvuId,
name,
description,
roomPid: body.roomPid,
rating: body.rating,
maxUsers: body.maxUsers ?? 10,
privacy: body.privacy ?? "public",
scene: [],
sceneHash: sceneHash(body.roomPid, []),
})
.returning();
return { room: r!, ownerName: link.username };
});
}
/** The owner's room, open (for changes). */
async own(ownerId: string, n: number) {
const r = await this.get(n, true, ownerId);
if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
return r;
}
async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) {
const { room: r, ownerName } = await this.own(ownerId, n);
if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const roomPid = changes.roomPid ?? r.roomPid;
let moderators: RoomModerator[] | undefined;
if (changes.moderators) {
const seen = new Set<number>([r.ownerImvuId]); // the owner isn't listed as a moderator of their own room
moderators = changes.moderators.filter((m) => !seen.has(m.imvuId) && seen.add(m.imvuId));
if (moderators.length > maxModerators)
throw new HostedError(400, "invalid", `A room has up to ${maxModerators} moderators.`);
}
const [updated] = await this.db
.update(hostedRoom)
.set({
...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}),
...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}),
...(changes.rating !== undefined ? { rating: changes.rating } : {}),
...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}),
...(changes.privacy !== undefined ? { privacy: changes.privacy } : {}),
...(moderators !== undefined ? { moderators } : {}),
...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}),
})
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) {
const { room: r, ownerName } = await this.own(ownerId, n);
const pid = roomPid ?? r.roomPid;
const [updated] = await this.db
.update(hostedRoom)
.set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) })
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
/** Closes a room: by its owner, or (moderator) anyone's. */
async close(actorId: string, moderator: boolean, n: number) {
const { room: r } = await this.get(n, true, actorId);
if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n));
return r;
}
// ---- Bans ----
/** A room the viewer owns or moderates (for its bans). */
async managed(viewer: Viewer, n: number) {
const r = await this.get(n, true, viewer.id);
if (r.room.ownerId !== viewer.id && !isRoomModerator(r.room, viewer.imvuIds))
throw new HostedError(403, "forbidden", "Only the room's owner and moderators can do this.");
return r;
}
async ban(n: number, imvuId: number, byImvuId: number, minutes: number) {
const until = minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
await this.db
.insert(hostedRoomBan)
.values({ n, imvuId, byImvuId, until })
.onConflictDoUpdate({ target: [hostedRoomBan.n, hostedRoomBan.imvuId], set: { byImvuId, until, createdAt: new Date() } });
}
private inForce = () => or(isNull(hostedRoomBan.until), gt(hostedRoomBan.until, new Date()));
async banned(n: number, imvuId: number): Promise<boolean> {
const [b] = await this.db
.select({ n: hostedRoomBan.n })
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId), this.inForce()));
return b !== undefined;
}
/** A room's bans in force, latest first. */
async bans(n: number) {
return this.db
.select()
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), this.inForce()))
.orderBy(desc(hostedRoomBan.createdAt));
}
/** Lifts a ban (owner only); false if there was none. */
async unban(ownerId: string, n: number, imvuId: number): Promise<boolean> {
await this.own(ownerId, n);
const gone = await this.db
.delete(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId)))
.returning();
return gone.length > 0;
}
// ---- Images ----
async setImage(ownerId: string, n: number, data: Buffer) {
const { ownerName } = await this.own(ownerId, n);
if (data.length > maxImageBytes) throw new HostedError(400, "invalid", "An image is up to 512 KB.");
const contentType = imageType(data);
if (!contentType) throw new HostedError(400, "invalid", "An image is a PNG or JPEG.");
const hash = createHash("sha256").update(data).digest("hex");
const room = await this.db.transaction(async (tx) => {
await tx
.insert(hostedRoomImage)
.values({ n, contentType, data, hash })
.onConflictDoUpdate({ target: hostedRoomImage.n, set: { contentType, data, hash, updatedAt: new Date() } });
const [r] = await tx.update(hostedRoom).set({ imageHash: hash }).where(eq(hostedRoom.n, n)).returning();
return r!;
});
return { room, ownerName };
}
async removeImage(ownerId: string, n: number) {
const { ownerName } = await this.own(ownerId, n);
await this.db.delete(hostedRoomImage).where(eq(hostedRoomImage.n, n));
const [room] = await this.db.update(hostedRoom).set({ imageHash: null }).where(eq(hostedRoom.n, n)).returning();
return { room: room!, ownerName };
}
async image(n: number) {
const [i] = await this.db.select().from(hostedRoomImage).where(eq(hostedRoomImage.n, n));
return i;
}
// ---- Favorites, ratings, visits ----
async favorite(userId: string, n: number, on: boolean) {
if (on) await this.db.insert(hostedRoomFavorite).values({ userId, n }).onConflictDoNothing();
else await this.db.delete(hostedRoomFavorite).where(and(eq(hostedRoomFavorite.userId, userId), eq(hostedRoomFavorite.n, n)));
}
/** Which of these rooms are the person's favorites. */
async favoriteSet(userId: string, ns: number[]): Promise<Set<number>> {
if (ns.length === 0) return new Set();
const rows = await this.db
.select({ n: hostedRoomFavorite.n })
.from(hostedRoomFavorite)
.where(and(eq(hostedRoomFavorite.userId, userId), inArray(hostedRoomFavorite.n, ns)));
return new Set(rows.map((r) => r.n));
}
/** Sets (1-5) or removes (null) the person's rating. */
async rate(userId: string, n: number, score: number | null) {
if (score === null) {
await this.db.delete(hostedRoomRating).where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return;
}
await this.db
.insert(hostedRoomRating)
.values({ userId, n, score })
.onConflictDoUpdate({ target: [hostedRoomRating.userId, hostedRoomRating.n], set: { score, updatedAt: new Date() } });
}
async myRating(userId: string, n: number): Promise<number> {
const [r] = await this.db
.select({ score: hostedRoomRating.score })
.from(hostedRoomRating)
.where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return r?.score ?? 0;
}
/** Each room's average rating (0 if none, two decimals) and how many rated it. */
async scores(ns: number[]): Promise<Map<number, { score: number; ratings: number }>> {
if (ns.length === 0) return new Map();
const rows = await this.db
.select({ n: hostedRoomRating.n, score: avg(hostedRoomRating.score), ratings: count() })
.from(hostedRoomRating)
.where(inArray(hostedRoomRating.n, ns))
.groupBy(hostedRoomRating.n);
return new Map(rows.map((r) => [r.n, { score: Math.round(Number(r.score ?? 0) * 100) / 100, ratings: r.ratings }]));
}
/** Records a visit (a join), keeping the person's latest 50. */
async visit(userId: string, n: number, imvuId: number) {
const at = new Date();
await this.db
.insert(hostedRoomVisit)
.values({ userId, n, imvuId, at })
.onConflictDoUpdate({ target: [hostedRoomVisit.userId, hostedRoomVisit.n], set: { imvuId, at } });
const keep = this.db
.select({ n: hostedRoomVisit.n })
.from(hostedRoomVisit)
.where(eq(hostedRoomVisit.userId, userId))
.orderBy(desc(hostedRoomVisit.at))
.limit(visitsKept);
await this.db.delete(hostedRoomVisit).where(and(eq(hostedRoomVisit.userId, userId), notInArray(hostedRoomVisit.n, keep)));
}
}
/** What realtime needs of a room. */
export function roomInfo({ room: r, ownerName }: Row): HostedRoomInfo {
return {
closed: r.closed,
adult: r.rating === "adult",
maxUsers: r.maxUsers,
privacy: r.privacy === "private" ? "private" : "public",
ownerImvuId: r.ownerImvuId,
moderators: r.moderators.map((m) => m.imvuId),
name: r.name,
ownerName,
};
}
+2 -2
View File
@@ -41,8 +41,8 @@ const options: sanitize.IOptions = {
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" }, attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
}), }),
}, },
// Images whose address was refused are dropped. // Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped.
exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src, exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""),
disallowedTagsMode: "discard", disallowedTagsMode: "discard",
}; };
+58 -4
View File
@@ -1,6 +1,6 @@
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass; // IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends // rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
// on it for nothing else. // (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
export type ImvuUser = { export type ImvuUser = {
id: number; id: number;
@@ -8,6 +8,7 @@ export type ImvuUser = {
tagline: string; tagline: string;
ageVerified: boolean; ageVerified: boolean;
accessPass: boolean; accessPass: boolean;
picture: string | null; // the avatar's picture (https, on *.imvu.com)
}; };
export type ImvuRoom = { export type ImvuRoom = {
@@ -25,6 +26,13 @@ export type ImvuRoom = {
expired: boolean; expired: boolean;
}; };
/** An IMVU product's public info, as T5 products derived from it show it. */
export type ImvuProduct = {
image: string | null; // https, on *.imvu.com
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
gender: string; // "female", "male", or "" (either)
};
export class NoSuchImvuUser extends Error { export class NoSuchImvuUser extends Error {
constructor() { constructor() {
super("no such IMVU account"); super("no such IMVU account");
@@ -65,6 +73,20 @@ export class ImvuClient {
} }
} }
/**
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
*/
async product(id: number): Promise<ImvuProduct | null> {
if (!Number.isSafeInteger(id) || id <= 0) return null;
try {
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
} catch (e) {
if (e instanceof NoSuchImvuUser) return null;
throw e;
}
}
private async wait() { private async wait() {
const now = Date.now(); const now = Date.now();
const at = Math.max(now, this.next); const at = Math.max(now, this.next);
@@ -72,7 +94,8 @@ export class ImvuClient {
if (at > now) await Bun.sleep(at - now); if (at > now) await Bun.sleep(at - now);
} }
private async get(path: string): Promise<unknown> { /** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
private async get(path: string, missing: number[] = []): Promise<unknown> {
await this.wait(); await this.wait();
let response: Response; let response: Response;
try { try {
@@ -83,7 +106,7 @@ export class ImvuClient {
} catch (e) { } catch (e) {
throw new ImvuUnavailable(String(e)); throw new ImvuUnavailable(String(e));
} }
if (response.status === 404) throw new NoSuchImvuUser(); if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`); if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
return response.json(); return response.json();
} }
@@ -115,6 +138,36 @@ export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undef
}; };
} }
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
export function imvuImage(v: unknown): string | null {
if (typeof v !== "string" || v.length > 1000) return null;
let url: URL;
try {
url = new URL(v.startsWith("//") ? "https:" + v : v);
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
return url.href;
}
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
const d = entry?.[1].data;
if (!d) return null;
const path = Array.isArray(d.category_path) ? d.category_path : [];
const categories = path.slice(0, 10).flatMap((c: unknown) => {
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
return [{ id: n, name: name.trim().slice(0, 100) }];
});
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
return { image: imvuImage(d.product_image), categories, gender };
}
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry. // IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
export function parse(answer: unknown): ImvuUser { export function parse(answer: unknown): ImvuUser {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {}; const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
@@ -130,5 +183,6 @@ export function parse(answer: unknown): ImvuUser {
tagline: typeof data.tagline === "string" ? data.tagline : "", tagline: typeof data.tagline === "string" ? data.tagline : "",
ageVerified: data.is_ageverified === true, ageVerified: data.is_ageverified === true,
accessPass: data.is_ap === true, accessPass: data.is_ap === true,
picture: imvuImage(data.thumbnail_url),
}; };
} }
+48 -3
View File
@@ -4,9 +4,19 @@
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101) // API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
// WEB_URL the website's address (default http://localhost:4100) // WEB_URL the website's address (default http://localhost:4100)
// PORT default 4101 // PORT default 4101
// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console
// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit
// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET
// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit
// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a
// check with ID document, liveness and face match costs $0.30)
import { openDatabase } from "@t5de/db"; import { openDatabase } from "@t5de/db";
import { AgeService, type Provider } from "./age";
import { diditAgeProvider } from "./age/didit";
import { imvuAgeProvider } from "./age/imvu";
import { createApp } from "./app"; import { createApp } from "./app";
import { createAuth } from "./auth"; import { createAuth } from "./auth";
import { Settings } from "./settings";
import { ImvuClient } from "./imvu"; import { ImvuClient } from "./imvu";
const env = process.env; const env = process.env;
@@ -21,13 +31,48 @@ const apiURL = env.API_URL ?? `http://localhost:${port}`;
const webURL = env.WEB_URL ?? "http://localhost:4100"; const webURL = env.WEB_URL ?? "http://localhost:4100";
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret }); const settings = new Settings(db, { registration: env.REGISTRATION });
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); const auth = createAuth(db, { apiURL, webURL, secret, settings });
const imvu = new ImvuClient();
function required(name: string): string {
const v = env[name];
if (!v) {
console.error(`${name} must be set`);
process.exit(1);
}
return v;
}
const ageProviders: Record<string, () => Provider> = {
imvu: () => imvuAgeProvider(imvu),
didit: () =>
diditAgeProvider({
apiKey: required("DIDIT_API_KEY"),
workflowId: required("DIDIT_WORKFLOW_ID"),
webhookSecret: required("DIDIT_WEBHOOK_SECRET"),
monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30),
}),
};
const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean);
if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer
for (const id of providers) {
if (!ageProviders[id]) {
console.error(`AGE_PROVIDERS: no provider ${id}`);
process.exit(1);
}
}
const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!()));
const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings });
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time. // Age answers that can change (IMVU's) are checked again when they expire, a few at a time; 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(() => { setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e)); rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo();
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+145 -20
View File
@@ -1,7 +1,23 @@
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do. // 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 { createHash, timingSafeEqual } from "node:crypto";
import { and, desc, eq, ilike, inArray, ne, or, sql, schema, type Database, type FileEntry } from "@t5de/db"; 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 { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys"; import type { Signer } from "./keys";
const { catalog, product, productRevision, hosting, library, user } = schema; const { catalog, product, productRevision, hosting, library, user } = schema;
@@ -55,10 +71,17 @@ export function checkFiles(files: unknown): FileEntry[] | undefined {
const sameFiles = (a: FileEntry[], b: FileEntry[]) => 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.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 { export class Products {
constructor( constructor(
private readonly db: Database, private readonly db: Database,
private readonly signer: Signer, private readonly signer: Signer,
private readonly imvu?: ImvuClient,
) {} ) {}
// ---- catalogs ---- // ---- catalogs ----
@@ -164,7 +187,7 @@ export class Products {
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000); const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub; const personId = claims.sub;
return this.db.transaction(async (tx) => { const made = await this.db.transaction(async (tx) => {
let p: Product; let p: Product;
if (typeof claims.product === "string") { if (typeof claims.product === "string") {
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update"); const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
@@ -209,6 +232,62 @@ export class Products {
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number }); await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: 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 ---- // ---- products ----
@@ -217,22 +296,63 @@ export class Products {
const [p] = await this.db.select().from(product).where(eq(product.id, productId)); 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) 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.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; return p;
} }
/** Products, newest first; adult ones only if adult. */ /** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) { async delete(personId: string, id: string) {
const conds = [ne(product.latest, 0)]; 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.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); 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.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 return this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username }) .select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(product) .from(product)
.innerJoin(user, eq(user.id, product.creatorId)) .innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds)) .where(and(...conds))
.orderBy(desc(product.createdAt)) .orderBy(desc(product.createdAt))
.limit(Math.min(opts.limit ?? 60, 200)); .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) { async get(id: string, adult: boolean) {
@@ -242,6 +362,7 @@ export class Products {
.innerJoin(user, eq(user.id, product.creatorId)) .innerJoin(user, eq(user.id, product.creatorId))
.where(eq(product.id, id)); .where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product."); 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."); if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db const revisions = await this.db
.select() .select()
@@ -271,16 +392,9 @@ export class Products {
} }
if (input.rating !== undefined) { if (input.rating !== undefined) {
if (input.rating === "adult") set.rating = "adult"; if (input.rating === "adult") set.rating = "adult";
else if (input.rating === "general" && p.rating === "adult") { else if (input.rating === "general" && p.rating === "adult")
// Lowering is reviewed first (design.md); refused for now unless every revision is general. // Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
const [adultRev] = await this.db throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult")))
.limit(1);
if (adultRev) throw new ProductError(400, "invalid", "A product with adult revisions stays adult.");
set.rating = "general";
}
} }
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning(); const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
return updated!; return updated!;
@@ -301,10 +415,19 @@ export class Products {
async library(personId: string) { async library(personId: string) {
return this.db return this.db
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt }) .select({
product,
stayOn: library.stayOn,
addedAt: library.addedAt,
creator: user.displayUsername,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(library) .from(library)
.innerJoin(product, eq(product.id, library.productId)) .innerJoin(product, eq(product.id, library.productId))
.where(eq(library.userId, personId)) .innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
.orderBy(desc(library.addedAt)); .orderBy(desc(library.addedAt));
} }
@@ -317,7 +440,9 @@ export class Products {
} }
async add(personId: string, productId: string, adult: boolean) { async add(personId: string, productId: string, adult: boolean) {
await this.get(productId, adult); 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(); await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
} }
+475 -4
View File
@@ -1,6 +1,6 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community // The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
// products it wears, and the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's // products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products). // community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
// //
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as // Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token): // a bearer token):
@@ -12,14 +12,70 @@
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]} // 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 // 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. // products only go to clients whose IMVU account counts as adult.
// hub -> proxy {"type": "decor", "room": "1-2"}
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
// with a chat in that room
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."} // hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
//
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
// proxy -> hub {"type": "room.leave", "room": n}
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}],
// "sequence": s} (the latest message number)
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
// "not_linked" | "too_many", "message": "..."}
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
// was unlinked or changed, or the room became adult)
//
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
export type Item = { id: string; name: string; adult: boolean }; export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] }; export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
/** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = {
closed: boolean;
adult: boolean;
maxUsers: number;
privacy: "public" | "private";
ownerImvuId: number;
moderators: number[];
name: string;
ownerName: string;
};
/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
export const hostedBase = 2_100_000_000;
/** The highest room number: chat IDs must stay XML-RPC integers. */
export const maxHostedN = 2_147_483_647 - hostedBase;
type Participant = { imvu_id: number; seat: number; name: string };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is). // 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. // 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 = { type Session = {
userId: string; userId: string;
imvuId: number; imvuId: number;
@@ -29,10 +85,25 @@ type Session = {
outfit: Item[]; outfit: Item[];
chats: Map<string, string>; chats: Map<string, string>;
lastSent: 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; send: (message: object) => void;
close: () => 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 = { export type RealtimeDeps = {
/** The person an IMVU account is linked to. */ /** The person an IMVU account is linked to. */
owner: (imvuId: number) => Promise<string | undefined>; owner: (imvuId: number) => Promise<string | undefined>;
@@ -40,20 +111,76 @@ export type RealtimeDeps = {
adult?: (userId: string, imvuId: number) => Promise<boolean>; adult?: (userId: string, imvuId: number) => Promise<boolean>;
/** A client entered an IMVU room (to learn about rooms for the rooms list). */ /** A client entered an IMVU room (to learn about rooms for the rooms list). */
room?: (instanceId: string) => void; 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 maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/; const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/; const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
const maxHostedPerSession = 5;
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
const sendBurst = 20;
const sendPerSecond = 10;
const maxWatched = 100;
const inviteLifetime = 10 * 60 * 1000;
const inviteBurst = 10;
const invitesPerMinute = 10;
const maxNote = 200;
const maxBanMinutes = 10 * 366 * 24 * 60;
const roomErrors = {
not_linked: "Sign in to T5DE with a linked IMVU account first.",
not_found: "No such room.",
closed: "This room is closed.",
adult: "This room is for adults.",
full: "This room is full.",
too_many: "You're in too many rooms.",
banned: "You're banned from this room.",
private: "This room is private.",
} as const;
// Reasons in room.boot_result and room.invite_result.
const actionErrors = {
not_linked: roomErrors.not_linked,
not_found: roomErrors.not_found,
closed: roomErrors.closed,
forbidden: "You can't do that in this room.",
not_in_room: "Join the room first.",
banned: "They're banned from this room.",
adult: "This room is for adults.",
rate_limited: "Too many invitations. Try again in a minute.",
} as const;
export class Realtime { export class Realtime {
private sessions = new Map<string, Session>(); private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private watchers = new Map<number, Set<string>>();
private invites = new Map<string, Invite>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>; private readonly deps: Required<RealtimeDeps>;
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) { constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps; const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = { adult: async () => false, room: () => {}, ...d }; this.deps = {
adult: async () => false,
room: () => {},
hostedRoom: async () => undefined,
hostedBanned: async () => false,
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
...d,
};
} }
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */ /** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
@@ -94,6 +221,13 @@ export class Realtime {
outfit: [], outfit: [],
chats: new Map(), chats: new Map(),
lastSent: new Map(), lastSent: new Map(),
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
watching: new Set(),
watchGen: 0,
invites: inviteBurst,
invitesRefilled: Date.now(),
send, send,
close, close,
}); });
@@ -101,6 +235,10 @@ export class Realtime {
close(id: string) { close(id: string) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (s) {
this.leaveAllHosted(id, s, false);
this.unwatch(id, s);
}
this.sessions.delete(id); this.sessions.delete(id);
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys()); if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
} }
@@ -125,8 +263,9 @@ export class Realtime {
unlinked(userId: string, imvuId: number) { unlinked(userId: string, imvuId: number) {
const chats: string[] = []; const chats: string[] = [];
let changed = false; let changed = false;
for (const s of this.sessions.values()) { for (const [id, s] of this.sessions) {
if (s.userId === userId && s.imvuId === imvuId) { if (s.userId === userId && s.imvuId === imvuId) {
this.leaveAllHosted(id, s, true);
s.imvuId = 0; s.imvuId = 0;
s.adult = false; s.adult = false;
s.outfit = []; s.outfit = [];
@@ -142,6 +281,12 @@ export class Realtime {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message); for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
} }
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
decorated(instance: string) {
const message = { type: "decor", room: instance };
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
}
/** Closes a person's connections (banned, or signed out of their computers). */ /** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) { disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close(); for (const s of this.sessions.values()) if (s.userId === userId) s.close();
@@ -171,6 +316,288 @@ export class Realtime {
} }
} }
// ---- Hosted rooms ----
/** How many people are in a hosted room now (IMVU accounts, each once). */
occupancy(n: number): number {
return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size;
}
/** A hosted room's participants: each IMVU account once, with the seat it last took and its IMVU name. */
participants(n: number): Participant[] {
const seats = new Map<number, { seat: number; name: string }>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name });
return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name }));
}
private toRoom(n: number, message: object, except?: string) {
for (const id of this.hosted.get(n)?.members.keys() ?? []) if (id !== except) this.sessions.get(id)?.send(message);
}
private participantsChanged(n: number, except?: string) {
const participants = this.participants(n);
this.toRoom(n, { type: "room.participants", room: n, participants }, except);
const occupancy = { type: "room.occupancy", room: n, participants };
for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy);
}
/** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to
* leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) });
this.participantsChanged(n);
}
private toAccount(imvuId: number, message: object) {
for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message);
}
private unwatch(id: string, s: Session) {
for (const n of s.watching) {
const w = this.watchers.get(n);
w?.delete(id);
if (w?.size === 0) this.watchers.delete(n);
}
s.watching.clear();
}
private leaveAllHosted(id: string, s: Session, tell: boolean) {
for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell);
}
/** A hosted room closed: everyone in it is told, and its state is dropped. */
hostedClosed(n: number) {
const watching = this.watchers.get(n);
if (watching) {
this.watchers.delete(n);
for (const w of watching) {
const s = this.sessions.get(w);
s?.watching.delete(n);
s?.send({ type: "room.occupancy", room: n, participants: [] });
}
}
for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id);
const room = this.hosted.get(n);
if (!room) return;
this.hosted.delete(n);
for (const id of room.members.keys()) {
const s = this.sessions.get(id);
s?.hosted.delete(n);
s?.send({ type: "room.closed", room: n });
}
}
/** A hosted room's settings changed: if it became adult, those who aren't leave. (A lower maxUsers only stops
* more from coming in.) */
hostedChanged(n: number, info: HostedRoomInfo) {
if (info.closed) return this.hostedClosed(n);
if (!info.adult) return;
for (const id of [...(this.hosted.get(n)?.members.keys() ?? [])]) {
const s = this.sessions.get(id);
if (s && !s.adult) this.leaveHosted(id, s, n, true);
}
}
/** Whether an account is the room's owner account, or one of its moderators. */
private static staff(info: HostedRoomInfo, imvuId: number) {
return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) };
}
private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise<object> {
const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] });
if (!s.imvuId) return refuse("not_linked");
const imvuId = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return refuse("not_found");
if (info.closed) return refuse("closed");
const adult = await this.deps.adult(s.userId, imvuId);
const name = await this.deps.accountName(imvuId);
const banned = await this.deps.hostedBanned(n, imvuId);
// The session may have closed or changed accounts while waiting.
if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked");
s.adult = adult;
if (info.adult && !s.adult) return refuse("adult");
if (banned) return refuse("banned");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
// A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder.
let admit: Invite | undefined;
const staff = Realtime.staff(info, imvuId);
if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) {
const inv = typeof invite === "string" ? this.invites.get(invite) : undefined;
if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private");
admit = inv;
}
if (s.hosted.size >= maxHostedPerSession) return refuse("too_many");
const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId));
if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full");
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0, admitted: new Set() }));
if (admit) {
this.invites.delete(admit.id); // single use
room.admitted.add(imvuId);
}
room.members.set(id, { imvuId, seat: 0, name });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId);
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */
private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) {
const target = m.imvu_id;
const ban = m.ban === undefined ? false : m.ban;
const minutes = m.minutes === undefined ? 0 : m.minutes;
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
if (typeof ban !== "boolean") return { type: "error", message: "bad ban" };
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes)
return { type: "error", message: "bad minutes" };
const result = (reason?: keyof typeof actionErrors) => ({
type: "room.boot_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }),
});
if (!s.imvuId) return result("not_linked");
const actor = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked");
const me = Realtime.staff(info, actor);
const them = Realtime.staff(info, target);
// Nobody boots the owner; moderators don't boot moderators; nobody boots themselves.
if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor)
return result("forbidden");
await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes });
const room = this.hosted.get(n);
room?.admitted.delete(target);
for (const [sid, member] of [...(room?.members ?? [])]) {
const other = this.sessions.get(sid);
if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted");
}
return result();
}
/** Whether a session may send another invitation now (10, then 10 a minute). */
private allowInvite(s: Session): boolean {
const now = Date.now();
s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute);
s.invitesRefilled = now;
if (s.invites < 1) return false;
s.invites -= 1;
return true;
}
/** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */
private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise<object | undefined> {
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
const result = (reason?: keyof typeof actionErrors, invite?: string) => ({
type: "room.invite_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }),
});
if (!s.imvuId) return result("not_linked");
const from = s.imvuId;
if (target === from) return { type: "error", message: "bad imvu_id" };
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
const banned = await this.deps.hostedBanned(n, target);
if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked");
const me = Realtime.staff(info, from);
if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id))
return result(info.privacy === "private" ? "forbidden" : "not_in_room");
if (banned) return result("banned");
const targets = [...this.sessions.values()].filter((t) => t.imvuId === target);
if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult");
if (!this.allowInvite(s)) return result("rate_limited");
const now = Date.now();
for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k);
const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
s.send(result(undefined, inviteId));
if (targets.length === 0) {
s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true });
return;
}
this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false });
const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName };
for (const t of targets) if (!info.adult || t.adult) t.send(message);
}
/** room.invite_reply: the invited account answers (the first answer counts). */
private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined {
const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined;
if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" };
if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" };
if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" };
if (inv.answered) return { type: "error", message: "already answered" };
inv.answered = true;
inv.accepted = m.accept;
if (!m.accept) this.invites.delete(inv.id);
// eslint-disable-next-line no-control-regex
const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote);
this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) });
}
/** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */
private async watch(id: string, s: Session, rooms: unknown): Promise<object | undefined> {
if (
!Array.isArray(rooms) ||
rooms.length > maxWatched ||
!rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN)
)
return { type: "error", message: "bad rooms" };
const gen = ++s.watchGen;
this.unwatch(id, s);
const ns = [...new Set(rooms as number[])];
const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n)));
if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came
for (const [i, n] of ns.entries()) {
const info = infos[i];
if (!info || info.closed || (info.adult && !s.adult)) continue;
const me = Realtime.staff(info, s.imvuId);
if (info.privacy === "private" && !me.owner && !me.moderator) continue;
s.watching.add(n);
let w = this.watchers.get(n);
if (!w) this.watchers.set(n, (w = new Set()));
w.add(id);
s.send({ type: "room.occupancy", room: n, participants: this.participants(n) });
}
}
/** Whether a session may send another room message now (a bucket of tokens, refilled over time). */
private allowSend(s: Session): boolean {
const now = Date.now();
s.tokens = Math.min(sendBurst, s.tokens + ((now - s.refilled) / 1000) * sendPerSecond);
s.refilled = now;
if (s.tokens < 1) return false;
s.tokens -= 1;
return true;
}
private sendHosted(id: string, s: Session, n: number, mount: unknown, message: unknown): object | undefined {
const room = this.hosted.get(n);
const me = room?.members.get(id);
if (!room || !me) return { type: "error", message: "not in that room" };
if (mount !== "messages" && mount !== "control") return { type: "error", message: "bad mount" };
if (typeof message !== "string" || Buffer.byteLength(message) > maxRoomMessage)
return { type: "error", message: "bad message" };
if (mount === "messages") {
// The client flags a message whose userId isn't its sender as forged; the hub doesn't pass those on at all.
const parsed = safeJSON(message) as { userId?: unknown } | null | undefined;
if (!parsed || typeof parsed !== "object" || String(parsed.userId) !== String(me.imvuId)) return;
}
if (!this.allowSend(s)) return;
room.sequence++;
this.toRoom(n, { type: "room.message", room: n, from: me.imvuId, mount, message, sequence: room.sequence }, id);
}
/** Handles a message from a proxy; returns the reply to send, if any. */ /** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> { async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id); const s = this.sessions.get(id);
@@ -181,12 +608,23 @@ export class Realtime {
items?: unknown; items?: unknown;
chat?: unknown; chat?: unknown;
room?: unknown; room?: unknown;
seat?: unknown;
mount?: unknown;
message?: unknown;
invite?: unknown;
ban?: unknown;
minutes?: unknown;
accept?: unknown;
note?: unknown;
rooms?: unknown;
}; };
if (!m || typeof m !== "object") return { type: "error", message: "not a message" }; if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") { if (m.type === "session") {
const imvuId = Number(m.imvu_id); const imvuId = Number(m.imvu_id);
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0; s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
const owner = s.requested ? await this.deps.owner(imvuId) : undefined; const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
if (owner !== s.userId) { if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it. // Not this person's account: no presence, no outfit for it.
s.imvuId = 0; s.imvuId = 0;
@@ -231,6 +669,39 @@ export class Realtime {
this.changed([chat]); this.changed([chat]);
return; 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" }; return { type: "error", message: "unknown message type" };
} }
} }
+29
View File
@@ -0,0 +1,29 @@
// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its
// default from the environment (so a new staging hub can start with registration closed).
import { eq, schema, type Database } from "@t5de/db";
const { hubSetting } = schema;
/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */
export type Registration = "open" | "closed";
export const registrations: readonly Registration[] = ["open", "closed"];
export class Settings {
constructor(
private readonly db: Database,
private readonly defaults: { registration?: string } = {},
) {}
async registration(): Promise<Registration> {
const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration"));
const value = row?.value ?? this.defaults.registration;
return value === "closed" ? "closed" : "open";
}
async setRegistration(value: Registration) {
await this.db
.insert(hubSetting)
.values({ key: "registration", value })
.onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } });
}
}
+147 -8
View File
@@ -1,8 +1,10 @@
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed // Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
// webhooks), and IMVU's answers changing. // and Didit's adapter against a fake Didit), limits on checks, and IMVU's answers changing.
import { createHmac } from "node:crypto";
import { afterAll, beforeAll, expect, test } from "bun:test"; import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, schema, type Database } from "@t5de/db"; import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, type Provider } from "../src/age"; import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
import { ageOn, diditAgeProvider } from "../src/age/didit";
import { imvuAgeProvider } from "../src/age/imvu"; import { imvuAgeProvider } from "../src/age/imvu";
import { createApp } from "../src/app"; import { createApp } from "../src/app";
import { createAuth } from "../src/auth"; import { createAuth } from "../src/auth";
@@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret";
const fakeVendor: Provider = { const fakeVendor: Provider = {
id: "vendor", id: "vendor",
label: "ID document", label: "ID document",
vendor: "Vendor",
scope: "person", scope: "person",
start: async () => { start: async () => {
const ref = `session-${crypto.randomUUID()}`; const ref = `session-${crypto.randomUUID()}`;
@@ -39,6 +42,45 @@ const fakeVendor: Provider = {
}, },
}; };
// Didit: sessions per person (an unfinished one is given again), and each session's decision.
const diditSecret = "didit-secret";
const diditSessions = new Map<string, { id: string; vendor: string; finished: boolean; decision?: unknown }>();
const fakeDidit: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
if (new Headers(init?.headers).get("x-api-key") !== "didit-key") return new Response("no key", { status: 401 });
if (init?.method === "POST" && url.pathname === "/v3/session/") {
const body = JSON.parse(String(init.body));
expect(body.workflow_id).toBe("workflow");
let s = [...diditSessions.values()].find((s) => s.vendor === body.vendor_data && !s.finished);
if (!s) {
s = { id: crypto.randomUUID(), vendor: body.vendor_data, finished: false };
diditSessions.set(s.id, s);
}
return Response.json({ session_id: s.id, url: `https://verify.didit.test/session/${s.id}`, status: "Not Started" }, { status: 201 });
}
const m = /^\/v3\/session\/([^/]+)\/decision\/$/.exec(url.pathname);
if (m && diditSessions.get(m[1]!)) return Response.json(diditSessions.get(m[1]!)!.decision ?? {});
return new Response("not found", { status: 404 });
}) as typeof fetch;
const didit = diditAgeProvider({ apiKey: "didit-key", workflowId: "workflow", webhookSecret: diditSecret, baseURL: "https://didit.test", fetch: fakeDidit });
/** A Didit webhook request, signed as Didit signs it (the raw body, or V2), or forged. */
function diditHook(body: Record<string, unknown>, sign: "raw" | "v2" | "forged" = "raw", timestamp = Math.floor(Date.now() / 1000)) {
const raw = JSON.stringify(body);
const hmac = (s: string, key = diditSecret) => createHmac("sha256", key).update(s).digest("hex");
const sortKeys = (v: unknown): unknown =>
Array.isArray(v)
? v.map(sortKeys)
: v && typeof v === "object"
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
: v;
const headers: Record<string, string> = { "X-Timestamp": String(timestamp), "Content-Type": "application/json" };
if (sign === "raw") headers["X-Signature"] = hmac(raw);
if (sign === "v2") headers["X-Signature-V2"] = hmac(JSON.stringify(sortKeys(body)));
if (sign === "forged") headers["X-Signature"] = hmac(raw, "wrong");
return fetch(`${base}/api/v1/age/webhook/didit`, { method: "POST", headers, body: raw });
}
let db: Database; let db: Database;
let close: () => Promise<void>; let close: () => Promise<void>;
let base: string; let base: string;
@@ -49,8 +91,8 @@ beforeAll(async () => {
({ db, close } = await openDatabase("memory")); ({ 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 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); const imvu = new ImvuClient("http://imvu.test", fakeIMVU);
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]); age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
const hub = createApp({ db, auth, imvu, age }); const hub = createApp({ db, auth, imvu, age, webURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" }); hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void }; const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop(); stop = () => server.stop();
@@ -78,7 +120,7 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
const cookie = { v: "" }; const cookie = { v: "" };
await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name }); 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) { for (const a of ids) {
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass }); 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}` }); const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` });
accounts.get(a.id)!.tagline = started.json.challenge.code; accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
@@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
return cookie; return cookie;
} }
const userOf = async (name: string) => (await db.select().from(schema.user)).find((u) => u.username === name)!.id;
const startedThisMonth = async () => {
const now = new Date();
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
return (await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.provider, "didit"))).filter((r) => r.createdAt >= month).length;
};
const adultOf = async (cookie: { v: string }) => 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])); Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult]));
@@ -102,7 +151,7 @@ test("IMVU's answer is per account, and re-checked when it expires", async () =>
test("a person's check covers all their accounts; webhooks must be signed", async () => { test("a person's check covers all their accounts; webhooks must be signed", async () => {
const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]); 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"]); expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor", "didit"]);
// IMVU checks accounts, not people: it can't be started this way. // 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); expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400);
@@ -118,6 +167,96 @@ test("a person's check covers all their accounts; webhooks must be signed", asyn
expect(await adultOf(b)).toEqual({ 10: true, 11: true }); expect(await adultOf(b)).toEqual({ 10: true, 11: true });
// A repeated webhook is harmless. // A repeated webhook is harmless.
expect((await hook(webhookSecret, true)).status).toBe(204); expect((await hook(webhookSecret, true)).status).toBe(204);
// Verified already: no more checks, with this provider or another.
const again = await call(b, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
expect(again).toMatchObject({ status: 409, json: { error: "refused" } });
});
test("people are sent back only to the hub's website", async () => {
const e = await personWithAccounts("eve", []);
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "https://evil.test/account" })).status).toBe(400);
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "//evil.test/account" })).status).toBe(400);
// A path is on the website.
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "/account?age=back" })).json.kind).toBe("redirect");
});
test("Didit: signed webhooks, the age on the document, and answers changed later", async () => {
const f = await personWithAccounts("fay", [{ id: 30 }]);
const started = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account?age=didit` })).json;
expect(started).toMatchObject({ kind: "redirect", url: `https://verify.didit.test/session/${started.ref}` });
// Going back to an unfinished check gives the same session, and doesn't count as another.
const resumed = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
expect(resumed.ref).toBe(started.ref);
expect((await call(f, "GET", "/api/v1/age")).json.checks).toHaveLength(1);
const event = (status: string, decision?: unknown) => ({
event_id: crypto.randomUUID(),
webhook_type: "status.updated",
session_id: started.ref,
status,
timestamp: Math.floor(Date.now() / 1000),
...(decision ? { decision } : {}),
});
const approved = event("Approved", { id_verifications: [{ status: "Approved", age: 31, date_of_birth: "1995-01-01" }] });
expect((await diditHook(approved, "forged")).status).toBe(401);
expect((await diditHook(approved, "raw", Math.floor(Date.now() / 1000) - 3600)).status).toBe(401); // stale
// In review: no answer yet.
expect((await diditHook(event("In Review"))).status).toBe(204);
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("pending");
expect(await adultOf(f)).toEqual({ 30: false });
expect((await diditHook(approved, "v2")).status).toBe(204);
expect(await adultOf(f)).toEqual({ 30: true });
const [row] = await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.providerRef, started.ref));
expect(row).toMatchObject({ provider: "didit", method: "id_document", status: "complete", overEighteen: true, imvuId: null });
// A reviewer at Didit declines it afterwards: the answer is taken back.
expect((await diditHook(event("Declined"))).status).toBe(204);
expect(await adultOf(f)).toEqual({ 30: false });
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("revoked");
});
test("Didit: under 18, and a decision fetched when the webhook has none", async () => {
const g = await personWithAccounts("gus", [{ id: 40 }]);
const started = (await call(g, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
const session = diditSessions.get(started.ref)!;
session.finished = true;
session.decision = { id_verifications: [{ date_of_birth: `${new Date().getUTCFullYear() - 17}-12-31` }] };
const body = { webhook_type: "status.updated", session_id: started.ref, status: "Approved", timestamp: 0 };
expect((await diditHook(body)).status).toBe(204);
expect(await adultOf(g)).toEqual({ 40: false });
expect((await call(g, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ status: "complete", overEighteen: false });
});
test("checks are limited per person and by a provider's monthly allowance", async () => {
const h = await personWithAccounts("hal", []);
// Each abandoned check is a new one.
for (let i = 0; i < attemptsPerPerson; i++) {
const s = (await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
diditSessions.get(s.ref)!.finished = true;
const body = { webhook_type: "status.updated", session_id: s.ref, status: "Abandoned", timestamp: 0 };
expect((await diditHook(body)).status).toBe(204);
}
const limited = await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
expect(limited).toMatchObject({ status: 409, json: { error: "refused" } });
expect(limited.json.message).toContain("30 days");
// The month's allowance is everyone's (here a service with room for one more).
const capped = new AgeService(db, [{ ...didit, monthlyLimit: (await startedThisMonth()) + 1 }]);
await personWithAccounts("ivy", []);
await personWithAccounts("joe", []);
const ivy = await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`);
await expect(capped.start({ userId: await userOf("joe") }, "didit", `${webURL}/account`)).rejects.toThrow("used up");
// Ivy can still go back to hers.
expect(await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`)).toEqual(ivy);
});
test("birth dates to ages", () => {
const day = new Date("2026-11-03T12:00:00Z");
expect(ageOn("2008-11-03", day)).toBe(18);
expect(ageOn("2008-11-04", day)).toBe(17);
expect(ageOn("2008-12-01", day)).toBe(17);
expect(ageOn("nonsense", day)).toBeUndefined();
}); });
test("a moved account takes nobody's age with it", async () => { test("a moved account takes nobody's age with it", async () => {
+121
View File
@@ -0,0 +1,121 @@
// The T5 badge on people: who shows it, as the proxy asks.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, proxy = false) {
const auth: Record<string, string> = p ? (proxy ? { Authorization: `Bearer ${p.token}` } : { Cookie: p.cookie }) : {};
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...auth },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up and links their IMVU accounts. */
async function person(name: string, ...ids: number[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, id] of ids.entries()) {
const username = `${name}Av${i}`;
accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200);
}
return p;
}
const badges = async (p: Person | undefined, imvuIds: unknown) => call(p, "POST", "/api/v1/badges", { imvuIds }, true);
test("only linked accounts whose badge is on, of people who aren't banned, get the badge", async () => {
const admin = await person("badgeadmin");
const ann = await person("ann", 301, 302);
const bob = await person("bob", 303);
const asker = await person("asker");
// Off by default.
const account = (await call(ann, "GET", "/api/v1/account")).json;
expect(account.links.map((l: any) => l.showBadge)).toEqual([false, false]);
expect((await badges(asker, [301, 302, 303, 999])).json).toEqual({ badges: [] });
// Turned on for one account; the other stays off.
const patched = await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: true });
expect(patched.json.link.showBadge).toBe(true);
await call(bob, "PATCH", "/api/v1/account/links/303", { showBadge: true });
const got = await badges(asker, [301, 302, 303, 999, 301]);
expect(got.status).toBe(200);
expect(got.json.badges.sort()).toEqual([301, 303]);
expect(Object.keys(got.json)).toEqual(["badges"]);
// Turned off again.
await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: false });
expect((await badges(asker, [301, 303])).json).toEqual({ badges: [303] });
// A banned person's accounts don't.
const bobId = (await call(bob, "GET", "/api/v1/me")).json.person.id;
await db.update(schema.user).set({ banned: true }).where(eq(schema.user.id, bobId));
expect((await badges(asker, [303])).json).toEqual({ badges: [] });
expect((await badges(asker, [])).json).toEqual({ badges: [] });
expect((await badges(admin, [303])).status).toBe(200);
});
test("lookups are validated and need signing in", async () => {
const p = await person("carol");
expect((await badges(undefined, [1])).status).toBe(401);
expect((await badges(p, Array.from({ length: 201 }, (_, i) => i + 1))).status).toBe(400);
expect((await badges(p, Array.from({ length: 200 }, (_, i) => i + 1))).status).toBe(200);
for (const bad of [[0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], ["x"], null, "x", { id: 1 }])
expect(JSON.stringify(bad) + " " + (await badges(p, bad)).status).toBe(JSON.stringify(bad) + " 400");
expect((await call(p, "POST", "/api/v1/badges", {}, true)).status).toBe(400);
});
+239
View File
@@ -0,0 +1,239 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
// proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string; id: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
id: "",
};
expect(p.token).not.toBe("");
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
for (const [i, a] of imvu.entries()) {
const username = `${name}Av${i || ""}`;
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return p;
}
/** A registered product with one revision (retracted: that revision is). */
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
}
/** A proxy's live connection, signed in to the client as imvuId. */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
send({ type: "session", imvu_id: imvuId });
await Bun.sleep(30);
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
test("only the room's owner decorates it; contents are checked", async () => {
const owner = await person("dora", { id: 501 }, { id: 502 });
const other = await person("dave", { id: 511 });
const unlinked = await person("dina");
await product("aaaaaaaaaa", owner.id, "general");
const room = "502-3";
const item = [7, "aaaaaaaaaa", "0 0 0"];
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
// Validation.
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
expect((await put([item], bad)).status).toBe(400);
expect((await put([item], "12345-2100000005")).json.error).toBe("hosted_room");
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
expect((await put([[7, 12345, ""]])).status).toBe(400);
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
expect((await put([item, item])).status).toBe(400);
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
expect(unknown.status).toBe(400);
expect(unknown.json.error).toBe("unknown_product");
// The owner's other account is fine too; up to 300 items.
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
expect(ok.status).toBe(200);
expect(ok.json.contents).toHaveLength(300);
const set = await put([item]);
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
// Not another person's room of the same number.
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
});
test("viewers see the products they may, with a hash of what they see", async () => {
const owner = await person("eli", { id: 601, ageVerified: true });
const kid = await person("emma", { id: 611 });
const grown = await person("evan", { id: 621, ageVerified: true });
await product("cccccccccc", owner.id, "general");
await product("dddddddddd", owner.id, "adult");
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
const room = "601-1";
const g = [1, "cccccccccc", "a"];
const a = [2, "dddddddddd", "b"];
const r = [3, "eeeeeeeeee", "c"];
// Unknown rooms have nothing.
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations/12345-2100000001")).status).toBe(400);
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
expect(put.status).toBe(200);
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
// A product kept but hidden comes back once it has a usable revision again.
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
expect((await view(kid)).contents).toEqual([g, r]);
// Changes change the hash.
const before = (await view(kid)).hash;
const moved = [1, "cccccccccc", "moved"];
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
const after = await view(kid);
expect(after.contents).toEqual([moved, r]);
expect(after.hash).not.toBe(before);
// Several rooms at once, in the order asked.
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
expect(many.json.rooms).toEqual([
{ instance: "601-9", contents: [], hash: hash([]) },
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
]);
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
// An empty list removes them.
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
});
test("proxies in the room are told when its decorations change", async () => {
const owner = await person("fay", { id: 701 });
const guest = await person("finn", { id: 711 });
const away = await person("fred", { id: 721 });
await product("ffffffffff", owner.id, "general");
const room = "701-2";
const host = await proxy(owner, 701); // the owner's client, in the room
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
const inRoom = await proxy(guest, 711);
const elsewhere = await proxy(away, 721);
host.send({ type: "join", chat: "1400000001", room });
inRoom.send({ type: "join", chat: "1400000001", room });
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
elsewhere.send({ type: "join", chat: "1400000003" });
await Bun.sleep(50);
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
await Bun.sleep(50);
expect(editor.decor()).toHaveLength(0);
expect(elsewhere.decor()).toHaveLength(0);
// Not after leaving.
inRoom.send({ type: "leave", chat: "1400000001" });
await Bun.sleep(30);
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await waitFor(() => host.decor().length === 2);
await Bun.sleep(50);
expect(inRoom.decor()).toHaveLength(1);
// A refused change tells no one.
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await Bun.sleep(50);
expect(host.decor()).toHaveLength(2);
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
});
+656
View File
@@ -0,0 +1,656 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, a] of imvu.entries()) {
const username = `${name}Av${i || ""}`;
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return p;
}
/** A proxy's live connection, signed in to the client as imvuId (0: none). */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
if (imvuId) {
send({ type: "session", imvu_id: imvuId });
await Bun.sleep(30);
}
const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1);
/** Sends a message and waits for the answer of that type for that room. */
const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => {
const before = got.length;
send(m);
await waitFor(() => got.slice(before).some(answer));
return got.slice(before).find(answer);
};
const join = (room: number) =>
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
return { ws, got, send, last, join, ask };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const newRoom = (imvuId: number, over: Record<string, unknown> = {}) => ({
name: "Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
imvuId,
...over,
});
test("rooms are made, listed, changed and closed by their owners", async () => {
const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 });
const ben = await person("benn", { id: 311 });
// Validation and permissions.
expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid");
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked");
expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult");
// Made: owned by the chosen account, shown with its IMVU name.
const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined }));
expect(made.status).toBe(201);
const n = made.json.room.n;
expect(made.json.room).toMatchObject({
instanceId: `302-${2_100_000_000 + n}`,
chatId: 2_100_000_000 + n,
name: "The Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
ownerImvuId: 302,
ownerName: "adamAv1",
occupancy: 0,
});
const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room;
expect(adult.rating).toBe("adult");
// Listed newest first; adult rooms only for adults.
const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name);
expect(await names(ada)).toEqual(["Late", "The Lounge"]);
expect(await names(ben)).toEqual(["The Lounge"]);
expect(await names()).toEqual(["The Lounge"]);
expect(await names(ben, "?mine=1")).toEqual([]);
expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]);
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401);
// One room, with its scene.
const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(got.json.scene.contents).toEqual([]);
expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400);
// Changes: owner only.
expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400);
const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" });
expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 });
// The scene: owner only, checked.
const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] };
expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400);
const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) };
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400);
const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 });
expect(put.status).toBe(200);
expect(put.json.scene.contents).toEqual(scene.contents);
expect(put.json.scene.hash).not.toBe(got.json.scene.hash);
const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(after.json.scene).toEqual(put.json.scene);
expect(after.json.room.roomPid).toBe(4321);
// Closing: owner only; then it's gone from lists and answers 410.
expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403);
expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed");
expect(await names(ada)).toEqual(["Late"]);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410);
// Unlinking the owner account hides its rooms.
await call(ada, "DELETE", "/api/v1/account/links/301");
expect(await names(ada)).toEqual([]);
});
test("a person may own up to 10 open rooms", async () => {
const cy = await person("cyd", { id: 321 });
const ns: number[] = [];
for (let i = 0; i < 10; i++) {
const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` }));
expect(r.status).toBe(201);
ns.push(r.json.room.n);
}
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap");
await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`);
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201);
});
test("joining, seats and messages in a hosted room", async () => {
const dee = await person("deen", { id: 401, ageVerified: true });
const eli = await person("elia", { id: 402 });
const fay = await person("faye", { id: 403 });
const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room;
const n: number = room.n;
const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n;
const d = await proxy(dee, 401);
const e = await proxy(eli, 402);
const f = await proxy(fay, 403);
// Refusals: no linked account in use, no such room, adult room for a non-adult.
const nobody = await proxy(eli, 0);
expect((await nobody.join(n)).reason).toBe("not_linked");
nobody.ws.close();
const notMine = await proxy(eli, 403); // Fay's account, not Eli's
expect((await notMine.join(n)).reason).toBe("not_linked");
notMine.ws.close();
expect((await e.join(999_999)).reason).toBe("not_found");
expect((await e.join(adultRoom)).reason).toBe("adult");
e.send({ type: "room.join", room: 0 });
await waitFor(() => e.got.some((m) => m.message === "bad room"));
// Dee and Eli join; each sees the other.
const dj = await d.join(n);
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 });
const ej = await e.join(n);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
// Full at two people; joining again (a reconnect) answers the same.
expect((await f.join(n)).reason).toBe("full");
expect((await e.join(n)).type).toBe("room.joined");
// Seats.
e.send({ type: "room.seat", room: n, seat: 3 });
await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3));
e.send({ type: "room.seat", room: n, seat: 100 });
await waitFor(() => e.got.some((m) => m.message === "bad seat"));
// Messages go to the others, numbered; forged senders are dropped.
const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") });
e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" });
e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) });
await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2);
await Bun.sleep(50);
const msgs = d.got.filter((m) => m.type === "room.message");
expect(msgs).toEqual([
{ type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 },
{ type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 },
]);
expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender
e.send({ type: "room.send", room: n, mount: "other", message: "x" });
await waitFor(() => e.got.some((m) => m.message === "bad mount"));
e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) });
await waitFor(() => e.got.some((m) => m.message === "bad message"));
f.send({ type: "room.send", room: n, mount: "control", message: "{}" });
await waitFor(() => f.got.some((m) => m.message === "not in that room"));
// Rate: a burst of 20, then the rest is dropped.
for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` });
await Bun.sleep(150);
const burst = d.got.filter((m) => m.type === "room.message").length - 2;
expect(burst).toBeGreaterThanOrEqual(18);
expect(burst).toBeLessThanOrEqual(23);
expect((await d.join(n)).sequence).toBe(2 + burst);
// Leaving makes room; a closed connection leaves too.
e.send({ type: "room.leave", room: n });
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
expect((await f.join(n)).type).toBe("room.joined");
f.ws.close();
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
// Closing tells everyone in it; joining it again is refused.
expect((await e.join(n)).type).toBe("room.joined");
expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined);
expect((await e.join(n)).reason).toBe("closed");
d.ws.close();
e.ws.close();
});
test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => {
const gil = await person("gilt", { id: 501, ageVerified: true });
const hal = await person("hale", { id: 502 });
const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n;
const g = await proxy(gil, 501);
const h = await proxy(hal, 502);
await g.join(n);
await h.join(n);
expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200);
await waitFor(() => h.last("room.closed", n) !== undefined);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
expect(g.last("room.closed", n)).toBeUndefined();
await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" });
await h.join(n);
await waitFor(() => g.last("room.participants", n)?.participants.length === 2);
await call(hal, "DELETE", "/api/v1/account/links/502");
await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
g.ws.close();
h.ws.close();
});
// ---- Phase 2 ----
test("moderators boot and ban; owners lift bans", async () => {
const own = await person("ownr", { id: 601 });
const mod = await person("modr", { id: 602 });
const usr = await person("usrr", { id: 603 });
const md2 = await person("mod2", { id: 604 });
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
const many = Array.from({ length: 21 }, (_, i) => i + 1);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
"no_such_imvu_account",
);
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
expect(set.status).toBe(200);
expect(set.json.room.moderators).toEqual([602, 604, 605]);
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
const o = await proxy(own, 601);
const m = await proxy(mod, 602);
const u = await proxy(usr, 603);
const m2 = await proxy(md2, 604);
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
// Who may boot whom.
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
// A boot: out, but may come back.
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
expect((await u.join(n)).type).toBe("room.joined");
// A ban: out, and refused.
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
// Bans: listed for the owner and moderators; lifted by the owner only.
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
expect((await u.join(n)).type).toBe("room.joined");
// A ban for a while; the owner may boot a moderator.
await boot(o, 603, { ban: true, minutes: 60 });
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
expect((await boot(o, 604)).ok).toBe(true);
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
// Logged.
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
const actions = log.map((e) => e.action);
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
expect(actions).toContain("hosted_room.unban");
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
for (const p of [o, m, u, m2]) p.ws.close();
});
test("private rooms and invitations", async () => {
const pam = await person("pamm", { id: 701 });
const quin = await person("quin", { id: 702 });
const rex = await person("rexx", { id: 703 }, { id: 705 });
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
// Listed only for the owner and moderators.
const listed = async (p?: Person, q = "") =>
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
expect(await listed()).toBe(false);
expect(await listed(rex)).toBe(false);
expect(await listed(pam)).toBe(true);
expect(await listed(quin)).toBe(true);
expect(await listed(pam, "?mine=1")).toBe(true);
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
const p = await proxy(pam, 701);
const q = await proxy(quin, 702);
const r = await proxy(rex, 703);
expect((await r.join(n)).reason).toBe("private");
expect((await q.join(n)).type).toBe("room.joined");
const invite = (from: typeof p, room: number, imvu_id: number) =>
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
const joinWith = (x: typeof p, invite: string) =>
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
// Invited: the invitee's sessions are told; it answers; the inviter hears.
const sent = await invite(p, n, 703);
expect(sent.ok).toBe(true);
const id: string = sent.invite;
await waitFor(() => r.last("room.invited", n) !== undefined);
expect(r.last("room.invited", n)).toEqual({
type: "room.invited",
room: n,
invite: id,
from: 701,
room_name: "Hideout",
owner_name: "pammAv",
});
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
await waitFor(() => p.last("room.invite_answer") !== undefined);
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
r.send({ type: "room.invite_reply", invite: id, accept: false });
await waitFor(() => r.got.some((x) => x.message === "already answered"));
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
expect((await joinWith(r, id)).type).toBe("room.joined");
r.ws.close();
const r2 = await proxy(rex, 703);
expect((await r2.join(n)).type).toBe("room.joined");
r2.send({ type: "room.leave", room: n });
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
expect((await joinWith(r2, id)).reason).toBe("private");
// Declined.
const second = (await invite(q, n, 703)).invite;
await waitFor(() => r2.got.some((x) => x.invite === second));
r2.send({ type: "room.invite_reply", invite: second, accept: false });
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
expect((await joinWith(r2, second)).reason).toBe("private");
// Nobody online as that account; banned accounts aren't invited.
const off = await invite(p, n, 705);
expect(off.ok).toBe(true);
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
type: "room.invite_answer",
invite: off.invite,
to: 705,
accept: false,
offline: true,
});
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
// Public rooms: anyone in it invites.
expect((await r2.join(pub)).type).toBe("room.joined");
expect((await invite(r2, pub, 701)).ok).toBe(true);
await waitFor(() => p.last("room.invited", pub) !== undefined);
for (const x of [p, q, r2]) x.ws.close();
});
const png = (bytes = 64) => {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return b;
};
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
const form = new FormData();
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
method: "PUT",
headers: { Origin: webURL, Cookie: p.cookie },
body: form,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
test("room images", async () => {
const ivy = await person("ivyy", { id: 801 });
const jon = await person("jonn", { id: 802 });
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
expect((await upload(jon, n, png())).status).toBe(403);
const gif = new TextEncoder().encode("GIF89a................................");
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect((await upload(ivy, n, svg)).status).toBe(400);
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
const up = await upload(ivy, n, png());
expect(up.status).toBe(200);
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(got.status).toBe(200);
expect(got.headers.get("content-type")).toBe("image/png");
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
const etag = got.headers.get("etag")!;
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
expect((await upload(ivy, n, jpeg)).status).toBe(200);
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(again.headers.get("content-type")).toBe("image/jpeg");
expect(again.headers.get("etag")).not.toBe(etag);
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
});
test("favorites, ratings and recent rooms", async () => {
const kim = await person("kimm", { id: 901 });
const lou = await person("louu", { id: 902 });
const mia = await person("miaa", { id: 903 });
const ns: number[] = [];
for (const name of ["One", "Two", "Three"])
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
const [a, b, c] = ns as [number, number, number];
// Favorites.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
expect(await favs(lou)).toEqual([c, a]);
expect(await favs(mia)).toEqual([]);
const flag = async (p: Person | undefined, n: number) =>
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
expect(await flag(lou, a)).toBe(true);
expect(await flag(mia, a)).toBe(false);
expect(await flag(undefined, a)).toBe(false);
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect(await favs(lou)).toEqual([c]);
// Ratings.
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
expect(one.json.myRating).toBe(4);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
// Recent rooms: joined, latest first, each once.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
const l = await proxy(lou, 902);
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
for (const n of [a, b, c]) {
await l.join(n);
await Bun.sleep(20);
}
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
expect(await recent()).toEqual([c, b, a]);
l.send({ type: "room.leave", room: a });
await Bun.sleep(30);
await l.join(a);
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
expect(await recent()).toEqual([a, c, b]);
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
expect(visit.map((v) => v.imvuId)).toEqual([902]);
l.ws.close();
});
test("watching rooms' occupancy", async () => {
const ned = await person("nedd", { id: 1001 });
const ola = await person("olaa", { id: 1002 });
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
const w = await proxy(ola, 1002);
const n = await proxy(ned, 1001);
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
// The private room's owner may watch it.
n.send({ type: "room.watch", rooms: [hidden] });
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
await n.join(a);
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
n.send({ type: "room.seat", room: a, seat: 2 });
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
n.send({ type: "room.leave", room: a });
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
await n.join(hidden);
await Bun.sleep(50);
expect(w.last("room.occupancy", hidden)).toBeUndefined();
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
// Replaced; closing tells watchers it's empty.
w.send({ type: "room.watch", rooms: [b] });
await Bun.sleep(50);
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
await n.join(a);
await n.join(b);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
w.ws.close();
n.ws.close();
});
+6 -6
View File
@@ -130,14 +130,14 @@ describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => { test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser(); const carol = new Browser();
await carol.signup("carol"); await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false }); accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null });
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" }); const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/); expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check"); const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409); expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing"); expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false }); const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null });
expect(linked.status).toBe(200); expect(linked.status).toBe(200);
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true }); expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe( expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
@@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => {
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser(); const dave = new Browser();
await dave.signup("dave"); await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false }); const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null });
expect(moved.json.moved).toBe(true); expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId); expect(after.userId).not.toBe(before.userId);
@@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => {
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id; const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200); expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false }); const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null });
expect(over.status).toBe(403); expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap"); expect(over.json.error).toBe("link_cap");
}); });
@@ -170,7 +170,7 @@ describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => { test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser(); const erin = new Browser();
await erin.signup("erin"); await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false }); await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null });
// The proxy asks for a code; until it's approved, the token request waits. // The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, { const codeRes = await fetch(`${base}/api/auth/device/code`, {
@@ -216,7 +216,7 @@ describe("T5DE's proxy", () => {
// An account linked while the client runs counts at once. // An account linked while the client runs counts at once.
ws.send(JSON.stringify({ type: "session", imvu_id: 78 })); ws.send(JSON.stringify({ type: "session", imvu_id: 78 }));
await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2); await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2);
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false }); await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null });
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78)); await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78));
await erin.call("DELETE", "/api/v1/account/links/78"); await erin.call("DELETE", "/api/v1/account/links/78");
await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78)); await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78));
+151 -6
View File
@@ -4,13 +4,23 @@ import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db"; import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app"; import { createApp } from "../src/app";
import { createAuth } from "../src/auth"; import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu"; import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test"; const webURL = "http://web.test";
const apiURL = "http://api.test"; const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>(); 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 fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input)); 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( const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
); );
@@ -25,11 +35,12 @@ let db: Database;
let close: () => Promise<void>; let close: () => Promise<void>;
let base: string; let base: string;
let stop: () => void; let stop: () => void;
let hub: ReturnType<typeof createApp>;
beforeAll(async () => { beforeAll(async () => {
({ db, close } = await openDatabase("memory")); ({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL }); hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" }); hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void }; const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop(); stop = () => server.stop();
@@ -66,7 +77,7 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean })
}); });
const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }; const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") };
if (imvu) { if (imvu) {
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false }); 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` }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code; accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
@@ -74,6 +85,9 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean })
return p; 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 }); 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 () => { test("catalogs register uploads; products have revisions, info and libraries", async () => {
@@ -92,6 +106,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect(reg.status).toBe(201); expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string; const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` }; const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
shared = { admin, catalogID, auth };
expect(reg.json.catalog.url).toBe("http://localhost:4102"); expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0); 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(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
@@ -119,11 +134,11 @@ test("catalogs register uploads; products have revisions, info and libraries", a
// Descriptions are cleaned by the allowlist. // Descriptions are cleaned by the allowlist.
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, { 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"></p>`, 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.status).toBe(200);
expect(edited.json.product.description).toBe( 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></p>`, `<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); expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
@@ -138,7 +153,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on. // 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, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]); 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; 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(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, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
@@ -165,6 +180,136 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]); 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"]); 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, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]); 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]);
});
+104
View File
@@ -0,0 +1,104 @@
// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's
// approval); admins switch it in the console, and the environment gives the default.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient } from "../src/imvu";
import { Settings } from "../src/settings";
const webURL = "http://web.test";
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
// Closed by default (as on staging), until an admin changes it.
const settings = new Settings(db, { registration: "closed" });
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings });
const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch);
const hub = createApp({ db, auth, imvu, webURL, settings });
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) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) },
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 signUp(name: string) {
const cookie = { v: "" };
expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200);
return cookie;
}
test("closed: new accounts wait for approval; the first one runs the hub", async () => {
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" });
const admin = await signUp("ada");
expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true });
expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200);
const bo = await signUp("bo_");
// Signed in, but nothing else.
expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false });
expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } });
expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403);
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
// Moderators see who's waiting, and approve them.
expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1);
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200);
expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]);
// An approved account can't be declined.
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400);
});
test("declining deletes the account", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const cy = await signUp("cyd");
const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(p.username).toBe("cyd");
expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200);
expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401);
expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401);
});
test("only admins open or close registration", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const mod = await signUp("dot");
const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0];
await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`);
await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" });
expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" });
expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403);
expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" });
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" });
// Open: new accounts can use the hub at once.
const eve = await signUp("eve");
expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200);
const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action);
expect(audit).toContain("hub.registration");
});
+1 -1
View File
@@ -88,7 +88,7 @@ async function person(name: string, imvu: { id: number; ageVerified?: boolean })
token: res.headers.get("set-auth-token") ?? "", token: res.headers.get("set-auth-token") ?? "",
}; };
expect(p.token).not.toBe(""); expect(p.token).not.toBe("");
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false }); 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` }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code; accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
+13
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
@@ -58,3 +59,15 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
return "Signed out everywhere."; return "Signed out everywhere.";
}); });
} }
/** Starts an age check with a provider that checks the person, and sends them to its page. */
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
let url: string | undefined;
const state = await attempt(async () => {
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
if (started.kind === "redirect") url = started.url;
done();
});
if (url) redirect(url);
return state;
}
+68
View File
@@ -0,0 +1,68 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AgeInfo } from "@/lib/api";
import { startAgeCheck } from "./actions";
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
const ways = age.providers.filter((p) => p.scope === "person");
if (ways.length === 0) return null;
const now = Date.now();
const verified = age.checks.find(
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
);
const latest = age.checks[0];
const pending = latest?.status === "pending" ? latest : undefined;
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
return (
<Card>
<CardHeader>
<CardTitle>Age Verification</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{verified ? (
<p className="flex flex-wrap items-center gap-2">
<Tag tone="adult">Age verified</Tag>
<span className="text-muted-foreground">
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
IMVU accounts count as adult.
</span>
</p>
) : (
<>
{pending ? (
<p>
{back
? "Thanks. Your check is being processed; this page shows the result when it's done."
: "Your check isn't finished yet."}
</p>
) : latest?.status === "complete" && !latest.overEighteen ? (
<p>Your last check found you're under 18.</p>
) : latest?.status === "failed" || latest?.status === "revoked" ? (
<p>Your last check didn&apos;t pass. You can try again.</p>
) : (
<p className="text-muted-foreground">
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
</p>
)}
{ways.map((p) => (
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
<div className="space-y-2">
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
</SubmitButton>
<p className="text-xs text-muted-foreground">
Done by {p.vendor}. T5DE only keeps whether you&apos;re over 18, never your ID, photo or birth date.
</p>
</div>
</ActionForm>
))}
</>
)}
</CardContent>
</Card>
);
}
+9 -5
View File
@@ -8,17 +8,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api"; import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions"; import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card"; import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card"; import { LinkCard } from "./link-card";
export const metadata: Metadata = { title: "Account" }; export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) { export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
const me = await getMe(); const me = await getMe();
if (!me) redirect("/signin?next=/account"); if (!me) redirect("/signin?next=/account");
const account = unwrap(await api.account.get()); const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]);
const { person, links, challenges, computers } = account; const { person, links, challenges, computers } = account;
const welcome = (await searchParams).welcome !== undefined; const params = await searchParams;
const welcome = params.welcome !== undefined;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,12 +80,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
</p> </p>
)} )}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Other people can&apos;t see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass) Other people can&apos;t see which IMVU accounts are yours. Adult IMVU accounts can see adult community
can see adult community products; each account is checked on its own. products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<AgeCard age={age} back={params.age === "back"} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>T5DE on your computers</CardTitle> <CardTitle>T5DE on your computers</CardTitle>
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { api, ApiError, unwrap } from "@/lib/api";
import { field } from "@/lib/actions";
export type RegisterState = { error?: string; registered?: { id: string; name: string; key: string } } | null;
/** Registers a catalog; its key is in the answer, the only time it's shown. */
export async function registerCatalog(_: RegisterState, form: FormData): Promise<RegisterState> {
try {
const { catalog, key } = unwrap(
await api.catalogs.post({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: field(form, "visibility"),
membership: field(form, "membership"),
}),
);
revalidatePath("/catalogs");
return { registered: { id: catalog.id, name: catalog.name, key } };
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import { CatalogStatus, membership } from "@/components/catalogs";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, getMe, unwrap, type Catalog } from "@/lib/api";
import { RegisterForm } from "./register-form";
export const metadata: Metadata = { title: "Catalogs" };
export default async function Catalogs() {
const [me, { catalogs }] = await Promise.all([getMe(), api.catalogs.get().then(unwrap)]);
const mine = catalogs.filter((c) => c.mine);
const directory = catalogs.filter((c) => !c.mine && c.status === "approved");
return (
<div className="space-y-6">
<PageHeader title="Catalogs">Servers that host community products.</PageHeader>
{mine.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Yours</CardTitle>
</CardHeader>
<CardContent>
<CatalogTable catalogs={mine} showStatus />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
</CardHeader>
<CardContent>
{directory.length === 0 ? (
<p className="text-sm text-muted-foreground">No catalogs yet.</p>
) : (
<CatalogTable catalogs={directory} />
)}
</CardContent>
</Card>
{me && (
<Card>
<CardHeader>
<CardTitle>Register a catalog</CardTitle>
<CardDescription>Run t5de-catalog, then register its address here.</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm />
</CardContent>
</Card>
)}
</div>
);
}
function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Membership</TableHead>
{showStatus && <TableHead>Visibility</TableHead>}
{showStatus && <TableHead>Status</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
{showStatus && (
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useActionState } from "react";
import { CopyButton } from "@/components/copy-button";
import { ErrorText, SubmitButton } from "@/components/forms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { registerCatalog } from "./actions";
export function RegisterForm() {
const [state, action] = useActionState(registerCatalog, null);
if (state?.registered) {
const { id, name, key } = state.registered;
return (
<Alert>
<AlertTitle>{name} is registered</AlertTitle>
<AlertDescription className="space-y-3">
<p>Put these in the catalog&apos;s settings. The key is shown only once. A moderator approves it next.</p>
<KeyLine label="CATALOG_ID" value={id} />
<KeyLine label="CATALOG_KEY" value={key} />
</AlertDescription>
</Alert>
);
}
return (
<form action={action}>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" required maxLength={300} placeholder="https://" />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" required maxLength={200} placeholder="Email or URL" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="visibility">Visibility</FieldLabel>
<NativeSelect id="visibility" name="visibility" defaultValue="public" className="w-full">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="membership">Membership</FieldLabel>
<NativeSelect id="membership" name="membership" defaultValue="open" className="w-full">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="approval">By approval</NativeSelectOption>
<NativeSelectOption value="invite">Invite only</NativeSelectOption>
</NativeSelect>
</Field>
</div>
</div>
<div>
<SubmitButton pendingLabel="Registering…">Register</SubmitButton>
</div>
{state?.error && <ErrorText>{state.error}</ErrorText>}
</FieldGroup>
</form>
);
}
function KeyLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-28 font-mono text-xs text-muted-foreground">{label}</span>
<code className="min-w-0 rounded-md border bg-muted px-2 py-1 font-mono text-xs break-all text-foreground">
{value}
</code>
<CopyButton text={value} />
</div>
);
}
+33
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
@@ -42,3 +43,35 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
return `Role set to ${person.role}.`; return `Role set to ${person.role}.`;
}); });
} }
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
return attempt(async () => {
unwrap(await api.console.catalogs({ id })({ decision }).post());
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
});
}
/** An account waiting for approval: approve it, or decline it (deleting it). */
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decline = field(form, "decision") === "decline";
const state = await attempt(async () => {
if (decline) unwrap(await api.console.people({ id }).decline.post());
else unwrap(await api.console.people({ id }).approve.post());
revalidatePath("/console", "layout");
});
if (decline && !state?.error) redirect("/console/people?waiting=true");
return state;
}
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
const registration = field(form, "registration") === "closed" ? "closed" : "open";
unwrap(await api.console.settings.put({ registration }));
revalidatePath("/console");
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
});
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { CatalogStatus, membership } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { decideCatalog } from "../actions";
import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "Catalogs" };
export default async function ConsoleCatalogs() {
await requireModerator("/console/catalogs");
const { catalogs } = unwrap(await api.console.catalogs.get());
return (
<>
<PageHeader title="Catalogs" />
<ConsoleNav />
<Card>
<CardContent>
{catalogs.length === 0 ? (
<p className="text-sm text-muted-foreground">None registered.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Takedown contact</TableHead>
<TableHead>Access</TableHead>
<TableHead>Registered</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-mono text-xs">{c.url}</TableCell>
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
<TableCell className="text-muted-foreground">
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={c.createdAt} dateOnly />
</TableCell>
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
<TableCell className="text-right">
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<div className="flex justify-end gap-2">
{c.status !== "approved" && (
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
)}
{c.status !== "rejected" && (
<SubmitButton
size="sm"
variant="outline"
name="decision"
value="reject"
pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`}
>
Reject
</SubmitButton>
)}
</div>
</ActionForm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+1
View File
@@ -17,6 +17,7 @@ export function ConsoleNav() {
Overview Overview
</NavLink> </NavLink>
<NavLink href="/console/people">People</NavLink> <NavLink href="/console/people">People</NavLink>
<NavLink href="/console/catalogs">Catalogs</NavLink>
</nav> </nav>
); );
} }
+41 -4
View File
@@ -1,19 +1,24 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list"; import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status"; import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time"; import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type AuditEntry } from "@/lib/api"; import { api, unwrap, type AuditEntry } from "@/lib/api";
import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav"; import { ConsoleNav, requireModerator } from "./console-nav";
export const metadata: Metadata = { title: "Console" }; export const metadata: Metadata = { title: "Console" };
export default async function Console() { export default async function Console() {
await requireModerator("/console"); const me = await requireModerator("/console");
const data = unwrap(await api.console.get()); const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
const stats = [ const stats = [
{ label: "People", value: data.counts.people, note: "accounts" }, { label: "People", value: data.counts.people, note: "accounts" },
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" }, { label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" }, { label: "Computers", value: data.counts.computers, note: "signed in" },
]; ];
@@ -23,18 +28,50 @@ export default async function Console() {
<PageHeader title="Console">Moderation.</PageHeader> <PageHeader title="Console">Moderation.</PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => ( {stats.map((s) => (
<Card key={s.label}> <Card key={s.label}>
<CardHeader> <CardHeader>
<CardDescription>{s.label}</CardDescription> <CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent> <CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card> </Card>
))} ))}
</div> </div>
<Card>
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
{me.role === "admin" && (
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="closed">Closed</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
)}
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Online now</CardTitle> <CardTitle>Online now</CardTitle>
+31 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, ApiError, unwrap } from "@/lib/api"; import { api, ApiError, unwrap } from "@/lib/api";
import { ban, setLinkCap, setRole, unban } from "../../actions"; import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions";
import { ConsoleNav, requireModerator } from "../../console-nav"; import { ConsoleNav, requireModerator } from "../../console-nav";
export const metadata: Metadata = { title: "Person" }; export const metadata: Metadata = { title: "Person" };
@@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str
joined <DateTime iso={person.createdAt} dateOnly /> joined <DateTime iso={person.createdAt} dateOnly />
</span> </span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>} {person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
</span> </span>
</PageHeader> </PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
{!person.approved && (
<Card>
<CardHeader>
<CardTitle>Waiting for Approval</CardTitle>
<CardDescription>
{person.email}. Until approved, they can only sign in. Declining deletes the account.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decideAccount} fields={{ id: person.id }}>
<div className="flex gap-2">
<SubmitButton name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
<SubmitButton
name="decision"
value="decline"
variant="outline"
pendingLabel="Declining…"
confirm={`Decline ${person.username}? Their account is deleted.`}
>
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>IMVU accounts</CardTitle> <CardTitle>IMVU accounts</CardTitle>
+30 -13
View File
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" }; export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { export default async function People({
const raw = (await searchParams).q; searchParams,
const q = typeof raw === "string" ? raw.slice(0, 64) : ""; }: {
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people"); searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} })); }) {
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.slice(0, 64) : "";
const waiting = params.waiting === "true";
const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString();
await requireModerator(`/console/people${query ? `?${query}` : ""}`);
const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } }));
return ( return (
<> <>
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<ConsoleNav /> <ConsoleNav />
<Card> <Card>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<form action="/console/people" className="flex max-w-md gap-2"> <div className="flex flex-wrap items-center gap-4">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" /> <form action="/console/people" className="flex max-w-md flex-1 gap-2">
<Button type="submit" variant="outline"> <Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
Search {waiting && <input type="hidden" name="waiting" value="true" />}
</Button> <Button type="submit" variant="outline">
</form> Search
</Button>
</form>
<Link
href={waiting ? "/console/people" : "/console/people?waiting=true"}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
{waiting ? "Show everyone" : "Waiting for approval"}
</Link>
</div>
{people.length === 0 ? ( {people.length === 0 ? (
<p className="text-sm text-muted-foreground">Nobody found.</p> <p className="text-sm text-muted-foreground">{waiting ? "Nobody is waiting." : "Nobody found."}</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly /> <DateTime iso={p.createdAt} dateOnly />
</TableCell> </TableCell>
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell> <TableCell className="text-right">
{p.banned ? <Tag tone="danger">banned</Tag> : !p.approved && <Tag tone="strong">waiting</Tag>}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Where creating a product used to be.
export default function Create() {
redirect("/products/new");
}
+34 -1
View File
@@ -128,4 +128,37 @@
html { html {
@apply font-sans; @apply font-sans;
} }
} }
/* Product descriptions: creators' HTML (cleaned by the API), laid out plainly. */
@layer components {
.product-description {
@apply space-y-3 text-sm leading-relaxed break-words;
}
.product-description :where(h1, h2, h3, h4, h5, h6) {
@apply font-semibold;
}
.product-description :where(h1) {
@apply text-xl;
}
.product-description :where(h2) {
@apply text-lg;
}
.product-description :where(a) {
@apply underline underline-offset-4;
}
.product-description :where(ul) {
@apply list-disc pl-5;
}
.product-description :where(ol) {
@apply list-decimal pl-5;
}
.product-description :where(img) {
@apply inline-block h-auto max-w-full;
}
.product-description :where(td, th) {
@apply border px-2 py-1;
}
.product-description :where(hr) {
@apply my-4;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

Before

Width:  |  Height:  |  Size: 397 B

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

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn" import { cn } from "cn"
const buttonVariants = cva( const variants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
@@ -39,16 +39,19 @@ const buttonVariants = cva(
} }
) )
// Merged, so a variant's border replaces the base's transparent one on links styled as buttons too.
const buttonVariants = (props?: Parameters<typeof variants>[0]) => cn(variants(props))
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof variants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={buttonVariants({ variant, size, className })}
{...props} {...props}
/> />
) )
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UploadForm } from "@/components/upload-form";
import { api, unwrap } from "@/lib/api";
/** Uploading to one of the approved catalogs: a new product, or with product, a new revision of it. */
export async function UploadCard({ title, product }: { title: string; product?: string }) {
const { catalogs } = await api.catalogs.get().then(unwrap);
const approved = catalogs.filter((c) => c.status === "approved").map((c) => ({ id: c.id, name: c.name }));
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{approved.length > 0 ? (
<UploadForm catalogs={approved} product={product} />
) : (
<p className="text-sm text-muted-foreground">
No catalogs to upload to yet. See{" "}
<Link href="/catalogs" className="text-foreground underline underline-offset-4">
catalogs
</Link>
.
</p>
)}
</CardContent>
</Card>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
// Uploading a product (or a new revision of one): the hub gives an upload token, then the browser sends the file to
// the catalog itself, so files never pass through the website's server.
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { startUpload } from "@/app/products/actions";
type Option = { id: string; name: string };
/** Sends form to url with the token, reporting progress; resolves with the catalog's status and JSON answer. */
function send(url: string, token: string, form: FormData, progress: (fraction: number) => void) {
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
xhr.upload.onprogress = (e) => e.lengthComputable && progress(e.loaded / e.total);
xhr.onload = () => {
let body: unknown = null;
try {
body = JSON.parse(xhr.responseText);
} catch {}
resolve({ status: xhr.status, body });
};
xhr.onerror = () => reject(new Error("network"));
xhr.send(form);
});
}
export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?: string }) {
const router = useRouter();
const [error, setError] = useState("");
const [progress, setProgress] = useState<number | null>(null);
async function submit(form: FormData) {
setError("");
setProgress(0);
const start = await startUpload(String(form.get("catalog")), product);
if ("error" in start) {
setError(start.error);
setProgress(null);
return;
}
const body = new FormData();
body.set("file", form.get("file") as File);
body.set("notes", String(form.get("notes") ?? ""));
try {
const answer = await send(start.url, start.token, body, setProgress);
const value = answer.body as { product?: unknown; message?: unknown } | null;
if (answer.status === 201 && typeof value?.product === "string") {
router.push(`/products/${value.product}`);
router.refresh();
return;
}
setError(
typeof value?.message === "string" && value.message
? value.message
: `The catalog refused the upload (${answer.status}).`,
);
} catch {
setError("Couldn't reach the catalog. Try again in a moment.");
}
setProgress(null);
}
const busy = progress !== null;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit(new FormData(e.currentTarget));
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="catalog">Catalog</FieldLabel>
<NativeSelect id="catalog" name="catalog" required className="w-full max-w-sm">
{catalogs.map((c) => (
<NativeSelectOption key={c.id} value={c.id}>
{c.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="file">File</FieldLabel>
<Input id="file" name="file" type="file" accept=".chkn,.zip" required className="max-w-sm" />
<FieldDescription>A Create Mode .chkn, or a zip of the product&apos;s files.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="notes">{product ? "What changed" : "Notes"}</FieldLabel>
<Textarea id="notes" name="notes" required={!!product} maxLength={4000} rows={3} />
</Field>
<div className="flex items-center gap-3">
<Button type="submit" disabled={busy}>
{busy ? "Uploading…" : "Upload"}
</Button>
{busy && (
<div
className="h-1.5 w-40 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
>
<div className="h-full bg-foreground transition-[width]" style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
{error && <ErrorText>{error}</ErrorText>}
</FieldGroup>
</form>
);
}
+8 -1
View File
@@ -37,9 +37,16 @@ export type Person = Data<V1["me"]["get"]>["person"];
export type Account = Data<V1["account"]["get"]>; export type Account = Data<V1["account"]["get"]>;
export type Link = Account["links"][number]; export type Link = Account["links"][number];
export type Challenge = Account["challenges"][number]; export type Challenge = Account["challenges"][number];
export type AgeInfo = Data<V1["age"]["get"]>;
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number]; export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>; export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number]; export type AuditEntry = ConsoleData["audit"][number];
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
export type Revision = ProductPage["revisions"][number];
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
/** An error the API answered: message is written to be shown as is. */ /** An error the API answered: message is written to be shown as is. */
export class ApiError extends Error { export class ApiError extends Error {
@@ -75,4 +82,4 @@ export const getMe = cache(async (): Promise<Person | undefined> => {
return data && "person" in data ? data.person : undefined; return data && "person" in data ? data.person : undefined;
}); });
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin"; export const isModerator = (p: Pick<Person, "role"> | undefined) => p?.role === "moderator" || p?.role === "admin";
+41
View File
@@ -0,0 +1,41 @@
// A Content Security Policy for every page: scripts only from this site with the request's nonce (Next.js adds it to
// its own scripts), so HTML that slips into a page, such as a product's description, can't run anything. Uploads go
// from the browser straight to catalogs, so connect-src allows any https address (and local ones, for development).
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const dev = process.env.NODE_ENV === "development";
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
// Descriptions keep their style attributes (the API's allowlist limits them to colors, sizes and alignment).
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https: data: blob:",
"font-src 'self'",
`connect-src 'self' https: http://localhost:* http://127.0.0.1:*${dev ? " ws:" : ""}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
headers.set("Content-Security-Policy", policy);
const response = NextResponse.next({ request: { headers } });
response.headers.set("Content-Security-Policy", policy);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|icon.svg|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
+45
View File
@@ -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)$"
+7
View File
@@ -22,6 +22,13 @@ services:
# T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api. # T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api.
API_URL: ${API_PUBLIC_URL:-http://localhost:4101} API_URL: ${API_PUBLIC_URL:-http://localhost:4101}
WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100} WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100}
# open, or closed (new accounts wait for a moderator's approval) until an admin changes it in the console.
REGISTRATION: ${REGISTRATION:-open}
# Ways to verify age (apps/api/src/index.ts): imvu, and didit with its keys.
AGE_PROVIDERS: ${AGE_PROVIDERS:-imvu}
DIDIT_API_KEY: ${DIDIT_API_KEY:-}
DIDIT_WORKFLOW_ID: ${DIDIT_WORKFLOW_ID:-}
DIDIT_WEBHOOK_SECRET: ${DIDIT_WEBHOOK_SECRET:-}
ports: ["4101:4101"] ports: ["4101:4101"]
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
+54 -9
View File
@@ -11,16 +11,17 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session tok
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`, is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`. `code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`.
## Accounts (Better Auth) ## Accounts (Better Auth)
| | | | | |
| --- | --- | | --- | --- |
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. | | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
| `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | | | `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}` | | `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
## Proxies ## Proxies
@@ -29,7 +30,8 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. | | `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` | | `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person | | `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, and the room channel. Protocol in `apps/api/src/realtime.ts`. | | `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. |
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel, decoration notices (`decor`) and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. |
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with | | `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
## Website routes (signed in unless noted) ## Website routes (signed in unless noted)
@@ -44,11 +46,51 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) | | `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` | | `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking | | `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` | | `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` (each off by default; `showBadge`: others see the T5 badge on this account in the client) |
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) | | `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
| `DELETE /api/v1/account/links/:imvu` | unlink | | `DELETE /api/v1/account/links/:imvu` | unlink |
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
## Hosted rooms
Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session;
the `GET`s also signed out.
| | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. |
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` |
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. |
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" |
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
privacy, moderators, moderatorNames, image, favorite, score, ratings,
createdAt}`; `instanceId` is `<owner IMVU ID>-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
## Decorations
T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance,
`<owner's customer ID>-<n>` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (room number 2100000000 and up) are refused
(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents`
is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the
sha256 (hex) of `contents` as JSON.
| | |
| --- | --- |
| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. |
| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked |
| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. |
## Products, catalogs and libraries
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
category ID) and `limit` (1-200, default 60).
## Age verification ## Age verification
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again; Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
@@ -56,17 +98,20 @@ 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 | | `GET /api/v1/age` | `{providers: [{id, label, vendor, scope}], checks}`: the providers in use and the person's own checks (newest first; status `pending`, `complete`, `failed` or `revoked`) |
| `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/start` | `{returnURL}` (a path on the website, or an address on it) → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API). An unfinished check is given again. 409 `refused`: already verified, or a limit reached (`message` says which); 502 `provider_unavailable` |
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | | `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 | | `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) |
## Console (moderators; roles: admins) ## Console (moderators; roles: admins)
| | | | | |
| --- | --- | | --- | --- |
| `GET /api/v1/console` | `{counts, connections, online, audit}` | | `GET /api/v1/console` | `{counts, connections, online, audit}` |
| `GET /api/v1/console/people?q=` | `{people}` | | `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) |
| `POST /api/v1/console/people/:id/approve` | `{person}` |
| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it |
| `GET /api/v1/console/settings`, `PUT` (admins) | `{registration}`: `open` or `closed` (default: the API's `REGISTRATION`, else open) |
| `GET /api/v1/console/people/:id` | `{person, links, computers}` | | `GET /api/v1/console/people/:id` | `{person, links, computers}` |
| `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) | | `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) |
| `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators | | `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators |
+29 -9
View File
@@ -100,6 +100,9 @@ age, not verified) and `tagline`.
- **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked: - **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked:
offer to link. Linked to another hub account: no community features that session. offer to link. Linked to another hub account: no community features that session.
- **Bans** apply to the person, so all linked accounts. - **Bans** apply to the person, so all linked accounts.
- **Registration** is open or closed, an admin's setting in the console (default from `REGISTRATION`; staging is
meant to be closed, for testers only). Closed: anyone can sign up, but the account can only sign in until a
moderator approves it (or declines it, deleting it); the hub treats it as signed out everywhere else.
## Age (**Decided**: free, several providers, vendor-neutral) ## Age (**Decided**: free, several providers, vendor-neutral)
@@ -112,8 +115,11 @@ No payment for verification (adult content rules out mainstream merchants of rec
2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet, 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 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. 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 3. **ID document and selfie** (live, `didit`): Didit's hosted check: ID document $0.15, passive liveness $0.10 and
(the liveness step stops someone using another person's ID). face match $0.05, so $0.30 a check, paid from the free plan's $10 of credit a month (since 2026-11-01; the 500
free checks a month are gone), so about 30 a month unless topped up. Scope: the person (liveness and face match
stop someone using another person's ID). Over 18 is worked out from the age Didit reads off the document;
nothing else is kept. With so few checks, phone wallets (free) should become the main way.
### How it's built ### How it's built
@@ -128,14 +134,25 @@ No payment for verification (adult content rules out mainstream merchants of rec
webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is 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 configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
one new adapter; nothing else changes. one new adapter; nothing else changes.
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature. - Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature. The provider's
latest answer stands: one taken back after approval (say, by a manual review) becomes `revoked`.
- **Limits** on checks of a person, for every provider: one unfinished check at a time (starting again gives the
same one), at most 3 started in 30 days, none once verified, and a provider's monthly cap (`monthlyLimit`; Didit's
`DIDIT_MONTHLY_LIMIT`, default 30), counted from the checks started this month (UTC).
- People are sent back only to the hub's website (`returnURL` is a path on it, or an address on `WEB_URL`).
- Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room - Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room
channels for viewers who aren't adult and opted in), and the viewer's proxy. channels for viewers who aren't adult and opted in), and the viewer's proxy.
### Setting up Didit
In Didit's console (business.didit.me): an application; a KYC workflow with ID verification, passive liveness and
face match only (no IP analysis, AML or proof of address, which cost extra; no white label, $0.10 a session more);
an API key; and a webhook destination
`<API_URL>/api/v1/age/webhook/didit` with its secret. Then the API's environment: `AGE_PROVIDERS=imvu,didit`,
`DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. Use the sandbox application on staging.
### To add the planned providers ### 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, - 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. mdoc); Apple and the EU wallet through the same browser API where supported.
@@ -287,8 +304,8 @@ is per person, shared across their accounts.
## Rooms (**Decided**: both) ## Rooms (**Decided**: both)
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements - **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps
travel on the room channel like outfits. them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations").
- **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions, - **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions,
bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of
IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the
@@ -305,7 +322,8 @@ is per person, shared across their accounts.
## Presence and badge (**Decided**: both opt-in, per IMVU account) ## Presence and badge (**Decided**: both opt-in, per IMVU account)
- **Badge**: other T5DE users see that this account uses T5DE. - **Badge**: other T5DE users see that this account uses T5DE. The proxy asks the hub which of the accounts it's
showing have it (`POST /api/v1/badges`, docs/api.md).
- **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't - **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't
store friend lists, but it sees them when asked (stated in the privacy text). store friend lists, but it sees them when asked (stated in the privacy text).
@@ -410,7 +428,9 @@ The proxy streams and adds no waiting.
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`. - Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
- Phase 2 (hub identity) under way: accounts, tagline linking, age (vendor-neutral, IMVU's re-checked daily), link - 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 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. The room channel is tested with 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. fake proxies and one live client; two live clients need a second throwaway account.
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on - **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
+197
View File
@@ -0,0 +1,197 @@
# T5DE-hosted rooms (phase 1)
A hosted room lives on the hub (design.md, Rooms): T5DE users find it in the client's room list, join it, sit and
chat, and its owner decorates it. The client treats it as an ordinary IMVU room: T5DE's proxy answers the client's
room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what the client expects). IMVU never
hears of hosted rooms.
Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the
owner's furniture), occupancy in the room list. Phase 2 (below): moderators, boots and bans, private rooms,
invitations, images, favorites, ratings, recent rooms, occupancy pushed to watchers, participant names.
## IDs
IDs IMVU won't use, so the proxy can tell hosted rooms apart:
- A room has a number `n` (1, 2, ...), its **instance ID** is `<owner's IMVU ID>-<2100000000 + n>` and its
**chat ID** is `2100000000 + n`. The client takes a room's owner from the instance ID's first part (so the owner
can decorate it); IMVU's own room numbers are small. (IMVU's chat IDs were about 1,390,000,000 in 2026-10, and XML-RPC integers end at
2,147,483,647; IMVU customer IDs, the first part of its instance IDs, are far below 2,100,000,000.)
- The room shell is an IMVU product (`roomPid`), chosen when the room is made (any room product; the client loads
it like any room).
## REST (bearer or cookie session)
| Route | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | `{name, description, roomPid, rating, maxUsers, imvuId}`: make a room. `imvuId` is one of the person's linked IMVU accounts, shown as the owner. `rating` `general` or `adult` (adult needs an adult person). `maxUsers` 2-30, default 10. Name 1-64 characters, description up to 512. A person may own up to 10 open rooms. |
| `GET /api/v1/hosted-rooms` | (also signed out) `{rooms: [room]}`, open rooms, newest first; adult rooms only for adults. `?mine=1`: the person's own. |
| `GET /api/v1/hosted-rooms/:n` | (also signed out) `{room, scene: {contents, hash}}`; 404 `not_found` (or adult, for others), 410 `closed`. |
| `PATCH /api/v1/hosted-rooms/:n` | owner: `{name?, description?, rating?, maxUsers?, roomPid?}`. |
| `PUT /api/v1/hosted-rooms/:n/scene` | owner: `{contents, roomPid?}`, from the client's `test.setRoomState`. |
| `DELETE /api/v1/hosted-rooms/:n` | owner (or moderator console): closes it; everyone in it is told (`room.closed`). |
A room: `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
createdAt}` (`ownerName`: the owner's IMVU name as linked; `occupancy`: people in it now). A room whose owner account
is no longer linked to its owner (unlinked, or moved to another person), or whose owner is banned, is hidden (not
listed, `not_found`) until it's linked again. Closed rooms stay closed; their numbers aren't reused.
The scene's `contents` is the client's `room_contents`: a list of `[furniture instance ID (integer), product ID
(integer or a hub product ID string), placement (string)]`, up to 500 items, placement up to 200 characters. `hash`
is a hash of the stored scene (the client caches scenes by it): sha256, hex, of `[roomPid, contents]` as JSON.
## Realtime (the proxy's WebSocket)
From the proxy (the session's IMVU account, sent with `session`, must be linked to the person):
- `{"type": "room.join", "room": n}`: join. Answer `{"type": "room.joined", "room": n, "chat": chatId,
"participants": [{"imvu_id", "seat"}], "sequence": s}` (the room's latest message number), or
`{"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" | "not_linked" | "too_many",
"message": "..."}` (`too_many`: the session is in 5 hosted rooms already). Joining again (a reconnect) answers the
same. A newcomer's seat is 0 until it sends `room.seat`. `maxUsers` counts IMVU accounts, so the same account
joining from a second session isn't refused as full.
- `{"type": "room.leave", "room": n}`.
- `{"type": "room.seat", "room": n, "seat": k}`: the person sat down (`*seat k`); 0-99.
- `{"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}` (up to 64 KB): to everyone
else in the room. For `messages` the hub checks the JSON's `userId` is the sender's IMVU account and drops it
otherwise (silently). Each session may send bursts of 20, then 10 a second; more is dropped. Malformed messages
(bad room, seat, mount, a message over 64 KB, sending to a room it isn't in) get `{"type": "error", "message"}`.
From the hub:
- `{"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}`: numbered
per room, from 1.
- `{"type": "room.participants", "room": n, "participants": [{"imvu_id", "seat"}]}`: whenever someone joins, leaves
or sits (not to the one joining: `room.joined` has the list).
- `{"type": "room.closed", "room": n}`: the room closed, or this session is out of it: its IMVU account was unlinked
or it sent `session` with another account, or the room became adult and its account isn't. (A lower `maxUsers`
sends no one out; it only stops newcomers.)
A session closing leaves its rooms. The hub keeps the participants and message numbers in memory; messages aren't
stored (design.md: chat retention).
## The proxy's part
- **XML-RPC** for hosted IDs, answered by the proxy (never sent to IMVU): `test.getRoomCardInfo`,
`chat.getOrMakeChat` (activity `publicroom-<owner>-<2100000000+n>`: joins, answers `chatId`, `seat`, `lastMessageId`,
`participantUrl`), `test.getRoomState` (the scene, with community products as local IDs), `chat.getParticipants`,
`chat.terminateChat`, `test.setRoomState` (owners: saved with `PUT .../scene`).
- **IMQ**: subscribing to `/chat/<chatId>` of a hosted room is answered by the proxy (result, `JoinedQueue`,
`CreateMount`s for `control`, `messages`, `participants`); `SendMessage` on it goes to the hub as `room.send`, and
`room.message` comes back as `G2cSendMessage`. `inv:/room/room-<owner>-<2100000000+n>` is answered with a result and a
`node` mount, and nothing more.
- **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/<owner>-<2100000000+n>`
queues are answered from `room.participants`.
## Phase 2
Moderators, boots and bans, private rooms, invitations, images, favorites, ratings, recent rooms, occupancy pushed
to watchers, and participants' names. Everything in phase 1 still holds; this adds to it.
### Rooms (REST)
A room (in every answer that has one) also has:
| Field | |
| --- | --- |
| `privacy` | `"public"` (default) or `"private"` |
| `moderators` | IMVU account IDs, up to 20, in the order the owner gave them (never the owner's own account) |
| `moderatorNames` | their IMVU names, same order: the name of the linked account, or as IMVU gave it when added by name; `""` when not known |
| `image` | `<API URL>/api/v1/hosted-rooms/<n>/image?v=<16 hex>` (absolute; `v` changes with the image), or `null` |
| `favorite` | whether it's one of the signed-in person's favorites (`false` signed out) |
| `score` | the average rating, 1-5, two decimals; `0` if nobody rated it. (Named `score`, not `rating`: `rating` is already the room's `general`/`adult`.) |
| `ratings` | how many people rated it |
Who's who: the room's **owner** is, over REST, the person who made it; over realtime, its owner IMVU account
(`ownerImvuId`). A **moderator** is an IMVU account in `moderators`; over REST, a person is a moderator if one of their
linked accounts is.
| Route | |
| --- | --- |
| `POST /api/v1/hosted-rooms` | also takes `privacy?` |
| `PATCH /api/v1/hosted-rooms/:n` | (owner) also takes `privacy?` and `moderators?`: up to 20 entries, each an IMVU ID (integer, or a string of digits) or an IMVU name (looked up: linked accounts first, then IMVU, rate-limited; unknown: 404 `no_such_imvu_account`). Replaces the list; duplicates and the owner's account are dropped. More than 20: 400 `invalid`. Making a room private sends no one out. |
| `GET /api/v1/hosted-rooms` | public rooms, and private ones the person owns or moderates. `?mine=1`: the person's own (private too). `?favorites=1`: their favorites, latest starred first. `?recent=1`: the last 20 distinct rooms they joined (any of their accounts), latest first. Those three need a signed-in person (401 `signed_out`); adult rooms only for adults, as before; closed and hidden rooms left out. |
| `GET /api/v1/hosted-rooms/:n` | anyone may get a private room (it's unlisted, not secret). Also `myRating`: the person's own rating, `0` if none or signed out. |
| `GET /api/v1/hosted-rooms/:n/participants` | (anyone who may `GET /:n`, also signed out; same 404/410) `{participants: [{imvu_id, seat, name}]}`, as in `room.participants` |
| `GET /api/v1/hosted-rooms/:n/bans` | (owner or moderator) `{bans: [{imvuId, byImvuId, until, createdAt}]}`: bans in force, latest first; `until` ISO time or `null` (for good). Others: 403 `forbidden`. |
| `DELETE /api/v1/hosted-rooms/:n/bans/:imvu` | (owner only) lifts a ban: 204; 404 if there's none. Logged (`hosted_room.unban`). |
| `PUT /api/v1/hosted-rooms/:n/image` | (owner) multipart, field `image`: a PNG or JPEG (by its magic bytes, whatever the declared type), up to 512 KB; else 400 `invalid`. Stored as uploaded. → `{room}`. 30 an hour. |
| `DELETE /api/v1/hosted-rooms/:n/image` | (owner) → `{room}` with `image: null` |
| `GET /api/v1/hosted-rooms/:n/image` | (anyone who may `GET /:n`) the image, with its `Content-Type`, `ETag` (`If-None-Match` answers 304), `Cache-Control: public, max-age=3600` (`private` for adult rooms), `X-Content-Type-Options: nosniff`; 404 if none |
| `PUT /api/v1/hosted-rooms/:n/favorite` | (signed in, a room they may see) stars it: 204, also if starred already |
| `DELETE /api/v1/hosted-rooms/:n/favorite` | 204 |
| `PUT /api/v1/hosted-rooms/:n/rating` | `{score}` (integer 1-5) → `{room, myRating}`; replaces their earlier rating. Owners can't rate their own room (403 `forbidden`). |
| `DELETE /api/v1/hosted-rooms/:n/rating` | removes their rating: 204 |
A visit is recorded when an IMVU account joins a hosted room (a new member; not a re-join of a session already in
it): per person, the room, the account and the time; the latest 50 per person are kept.
### Realtime
**Participants** everywhere (`room.joined`, `room.participants`, `room.occupancy`, `GET .../participants`) are
`{"imvu_id": X, "seat": k, "name": "..."}`; `name` is the IMVU username of the hub's verified link for that account
(as of when it joined), `""` if unknown.
**Joining** (`room.join`) may carry `"invite": id`. New `room.error` reasons, checked in this order after phase 1's
`not_linked`, `not_found`, `closed`, `adult`: `"banned"` (a ban in force on the session's account), then `"private"`
(a private room, and the account isn't its owner account, a moderator, already let in, or holding an accepted,
unexpired invite for this room and account), then `too_many`, `full`.
An invite used to join is spent, and the account is **let in**: it may join again without one (a reconnect, another
session) until it sends `room.leave` and none of its sessions is still in the room, or it's booted. (A connection
dropping doesn't end it. It's kept in memory: a hub restart ends it.)
**Boot and ban**, from the owner account or a moderator (needn't be in the room):
- proxy → hub `{"type": "room.boot", "room": n, "imvu_id": X, "ban"?: false, "minutes"?: 0}`. `ban` true stores a
ban; `minutes` 0 (default) is for good, else 1 to 5,270,400 (about ten years). Malformed: `{"type": "error",
"message": "bad imvu_id" | "bad ban" | "bad minutes" | "bad room"}`.
- hub → proxy (the sender) `{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": true, "ban": bool}`, or
`{"type": "room.boot_result", "room": n, "imvu_id": X, "ok": false, "reason": "...", "message": "..."}` with
`reason` `"not_linked"`, `"not_found"`, `"closed"` or `"forbidden"`: the sender isn't the owner account or a
moderator; X is the owner account (nobody boots the owner); X is a moderator and the sender isn't the owner
account; or X is the sender.
- Every session of X in the room gets `{"type": "room.closed", "room": n, "reason": "booted"}` and is out; the others
get `room.participants`; watchers `room.occupancy`. A boot without a ban doesn't stop X coming back (except into
a private room, which needs a new invite).
- Logged: `hosted_room.boot` or `hosted_room.ban`, target the room number, detail `{imvu: X, by: sender's IMVU ID}`
(and `minutes` for a ban), actor the sender's person. A banned moderator stays a moderator (the owner removes them
from `moderators`).
**Invitations**:
- proxy → hub `{"type": "room.invite", "room": n, "imvu_id": X}`: from a session in the room (public rooms), or from
the owner account or a moderator, in it or not (private rooms). 10, then 10 a minute per session.
- hub → proxy (the sender) `{"type": "room.invite_result", "room": n, "imvu_id": X, "ok": true, "invite": id}`, or
`ok: false` with `reason` `"not_linked"`, `"not_found"`, `"closed"`, `"forbidden"` (private room, not owner or
moderator), `"not_in_room"` (public room, the sender isn't in it), `"banned"` (X is banned from the room),
`"adult"` (an adult room, and none of X's sessions counts as adult) or `"rate_limited"`, and `message`. X being the
sender: `{"type": "error", "message": "bad imvu_id"}`.
- When `ok`: every session whose IMVU account is X gets `{"type": "room.invited", "room": n, "invite": id, "from":
sender's IMVU ID, "room_name": "...", "owner_name": "..."}` (`owner_name`: the owner account's IMVU name). If X has
no session, the sender instead gets `{"type": "room.invite_answer", "invite": id, "to": X, "accept": false,
"offline": true}` right after the result, and the invite is dropped.
- proxy (X's) → hub `{"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."}`. The first answer
counts. `note`: control characters become spaces, trimmed, up to 200 characters, left out when empty. Errors:
`{"type": "error", "message": "no such invite" | "already answered" | "bad accept" | "bad note"}` (`no such invite`
also when it expired or the session's account isn't X).
- hub → proxy (every session of the sender's IMVU account) `{"type": "room.invite_answer", "invite": id, "to": X,
"accept": bool, "note"?: "..."}`.
- An invite: single use, valid 10 minutes from when it was made (accepting doesn't extend it), for that room and
account. Kept in memory only; closing the room drops its invites. `invite` ids are opaque strings (base64url).
**Occupancy**:
- proxy → hub `{"type": "room.watch", "rooms": [n, ...]}`: replaces the session's watched set (up to 100; `[]`
stops). Not a list of room numbers, or over 100: `{"type": "error", "message": "bad rooms"}`. Rooms the session
may not see are left out silently: no such room, closed or hidden; adult rooms for a session whose account isn't
adult; private rooms unless its account is the owner account or a moderator. That's checked when it watches.
- hub → proxy, once per watched room right after, then whenever the room's participants change (join, leave, seat,
boot): `{"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}`. A room closing sends
`participants: []` and stops watching it. A session closing stops its watching.
### The proxy's part (phase 2)
- Room cards and lists: `privacy`, `image`, `score`/`ratings` from the room views; people in a room by name from
`participants` (`name`), or `GET .../participants` for a room it isn't in or watching.
- `room_occupancy/<owner>-<2100000000+n>` queues: from `room.watch`/`room.occupancy` instead of polling.
+34 -1
View File
@@ -59,10 +59,12 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
| `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) | | `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 | | `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 | | `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL |
| `GET /api/v1/products?q=&creator=` | (anyone) products, newest first; adult ones only for people with an adult account | | `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?}` | | `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 | | `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) | | `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 | | `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) | | `PUT /api/v1/library/:id` | get a product (add it) |
| `DELETE /api/v1/library/:id` | remove it | | `DELETE /api/v1/library/:id` | remove it |
@@ -72,6 +74,20 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes. 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) ### Console (moderators)
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`. `GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
@@ -85,3 +101,20 @@ The realtime connection also tells the proxy `{"type": "library"}` when the pers
From there it's served like any pack. 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. - 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). - 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.
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE "hosted_room" (
"n" serial PRIMARY KEY NOT NULL,
"owner_id" text NOT NULL,
"owner_imvu_id" bigint NOT NULL,
"name" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"room_pid" bigint NOT NULL,
"rating" text DEFAULT 'general' NOT NULL,
"max_users" integer DEFAULT 10 NOT NULL,
"scene" jsonb DEFAULT '[]'::jsonb NOT NULL,
"scene_hash" text DEFAULT '' NOT NULL,
"closed" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "hosted_room" ADD CONSTRAINT "hosted_room_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "hosted_room_owner" ON "hosted_room" USING btree ("owner_id");--> statement-breakpoint
CREATE INDEX "hosted_room_created" ON "hosted_room" USING btree ("created_at");
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE "room_decoration" (
"instance_id" text PRIMARY KEY NOT NULL,
"owner_imvu_id" bigint NOT NULL,
"contents" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id");
@@ -0,0 +1,53 @@
CREATE TABLE "hosted_room_ban" (
"n" integer NOT NULL,
"imvu_id" bigint NOT NULL,
"by_imvu_id" bigint NOT NULL,
"until" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_favorite" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_image" (
"n" integer PRIMARY KEY NOT NULL,
"content_type" text NOT NULL,
"data" "bytea" NOT NULL,
"hash" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hosted_room_rating" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"score" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
CREATE TABLE "hosted_room_visit" (
"user_id" text NOT NULL,
"n" integer NOT NULL,
"imvu_id" bigint NOT NULL,
"at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n")
);
--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint
ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint
CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at");
@@ -0,0 +1,3 @@
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
@@ -0,0 +1,7 @@
CREATE TABLE "hub_setting" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "approved" boolean DEFAULT true NOT NULL;
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
+42
View File
@@ -29,6 +29,48 @@
"when": 1793324955738, "when": 1793324955738,
"tag": "0003_products", "tag": "0003_products",
"breakpoints": true "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
},
{
"idx": 9,
"version": "7",
"when": 1793707706030,
"tag": "0009_registration",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema"; import * as schema from "./schema";
export { schema }; export { schema };
export type { FileEntry } from "./schema"; export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
export * from "drizzle-orm"; export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+161
View File
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
import { import {
bigint, bigint,
boolean, boolean,
customType,
index, index,
integer, integer,
jsonb, jsonb,
@@ -42,6 +43,8 @@ export const user = pgTable(
banExpires: timestamp("ban_expires", { withTimezone: true }), banExpires: timestamp("ban_expires", { withTimezone: true }),
// How many IMVU accounts the person may link; moderators raise it on request. // How many IMVU accounts the person may link; moderators raise it on request.
linkCap: integer("link_cap").notNull().default(10), linkCap: integer("link_cap").notNull().default(10),
// False while a new account waits for a moderator's approval (registration closed); it can only sign in.
approved: boolean("approved").notNull().default(true),
}, },
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)], (t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
); );
@@ -226,6 +229,15 @@ export const audit = pgTable(
export type FileEntry = { name: string; sha256: string; size: number }; 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. // The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
export const signingKey = pgTable("signing_key", { export const signingKey = pgTable("signing_key", {
id: text("id").primaryKey(), // the JWK's kid id: text("id").primaryKey(), // the JWK's kid
@@ -266,6 +278,11 @@ export const product = pgTable(
description: text("description").notNull().default(""), // HTML, already cleaned description: text("description").notNull().default(""), // HTML, already cleaned
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator 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 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(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
}, },
@@ -283,6 +300,7 @@ export const productRevision = pgTable(
files: jsonb("files").$type<FileEntry[]>().notNull(), files: jsonb("files").$type<FileEntry[]>().notNull(),
rating: text("rating").notNull(), rating: text("rating").notNull(),
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from 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(""), notes: text("notes").notNull().default(""),
token: text("token").notNull(), // the hub's t5de-revision token token: text("token").notNull(), // the hub's t5de-revision token
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }), uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
@@ -321,3 +339,146 @@ export const library = pgTable(
}, },
(t) => [primaryKey({ columns: [t.userId, t.productId] })], (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)],
);
// The hub's settings that admins change from the console (e.g. registration: "open" or "closed").
export const hubSetting = pgTable("hub_setting", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: updatedAt(),
});