Compare commits
15
Commits
d57674d90c
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ebe976ba | ||
|
|
efd5d52198 | ||
|
|
f8ab3f965e | ||
|
|
21e1bd8790 | ||
|
|
975e11e077 | ||
|
|
7d20426d1b | ||
|
|
e3770ceb57 | ||
|
|
97ce2be569 | ||
|
|
07bbc11565 | ||
|
|
76c8e0342e | ||
|
|
43183ec7ee | ||
|
|
f65f36c1ea | ||
|
|
2a1fc94df6 | ||
|
|
0c67232425 | ||
|
|
6668b55117 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
${PullRequestTitle} (#${PullRequestIndex})
|
||||
|
||||
${PullRequestDescription}
|
||||
+37
-1
@@ -1,9 +1,11 @@
|
||||
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub.
|
||||
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub; then, for main and vX.Y.Z
|
||||
# tags, builds its images and pushes them to git.t5de.dev. Komodo deploys them (dhkatz/infra); CI never deploys.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,3 +34,37 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
# main → :latest-main and :main-<commit>; vX.Y.Z → :X.Y.Z, :X.Y and :latest. Built by the runner's Docker.
|
||||
image:
|
||||
needs: test
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { image: t5de-hub-api, dockerfile: apps/api/Dockerfile }
|
||||
- { image: t5de-hub-web, dockerfile: apps/web/Dockerfile }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
IMAGE: git.t5de.dev/t5de/${{ matrix.image }}
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/heads/main) version="main-${GITHUB_SHA::12}"; tags="latest-main $version" ;;
|
||||
refs/tags/v*)
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Not a vX.Y.Z tag: no image"; exit 0; }
|
||||
tags="$version ${version%.*} latest" ;;
|
||||
*) echo "Not main or a tag: no image"; exit 0 ;;
|
||||
esac
|
||||
echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u deploy --password-stdin
|
||||
docker build -f "$DOCKERFILE" --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
|
||||
--label org.opencontainers.image.revision="$GITHUB_SHA" --label org.opencontainers.image.version="$version" \
|
||||
$(for t in $tags; do echo "-t $IMAGE:$t"; done) .
|
||||
for t in $tags; do docker push -q "$IMAGE:$t"; done
|
||||
docker logout git.t5de.dev
|
||||
@@ -0,0 +1,30 @@
|
||||
# A pull request's title becomes its commit on main (squash merges, .gitea/default_merge_message), so it must be a
|
||||
# conventional commit: the release workflow versions releases and writes the changelog from them. Required to merge
|
||||
# (branch protection, dhkatz/infra modules/app-repo).
|
||||
name: Pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Conventional commit title
|
||||
env:
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
types="feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert"
|
||||
conventional="^($types)(\([a-z0-9][a-z0-9./_-]*\))?!?: [^ ]"
|
||||
if ! [[ "$TITLE" =~ $conventional ]]; then
|
||||
echo "::error::The title becomes the commit on main, so it must be a conventional commit: type(scope): summary, e.g. \"feat(api): room bans\" or \"fix: crash on an empty catalog\" (! before the colon for a breaking change). Types: ${types//|/, }."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$TITLE" =~ ^chore\(release\) ]] && [[ "$BRANCH" != release || "$AUTHOR" != release-bot ]]; then
|
||||
echo "::error::chore(release) is release-bot's: merging its release pull request is what tags a release."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $TITLE"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Releases. release-bot keeps one pull request open from the `release` branch, "chore(release): vX.Y.Z": the next
|
||||
# version and its CHANGELOG.md entry, which git-cliff (cliff.toml) works out from the conventional commits on main
|
||||
# since the last tag. Merging it makes release-bot tag vX.Y.Z on main and publish the release notes; the tag builds the
|
||||
# image (ci.yml) and Komodo deploys it to prod (dhkatz/infra). Only release-bot may create v* tags. It acts with its
|
||||
# own token (the org secret RELEASE_TOKEN), not this workflow's: Gitea starts no workflows for what that one does, so
|
||||
# the release pull request's checks and the tag's build would never run.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLIFF_VERSION: 2.14.2
|
||||
CLIFF_SHA512: 95b0c55ab3e351ca7c0285fbbde8f1e8c42906466a173e6884f5c7ca814523bda44cbd4ab26b0aa28cace4c02c5ee6b553e9742bd9d7df47a73b9ef58ea429a9
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
archive="git-cliff-$CLIFF_VERSION-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl -fsSLO "https://github.com/orhun/git-cliff/releases/download/v$CLIFF_VERSION/$archive"
|
||||
echo "$CLIFF_SHA512 $archive" | sha512sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$archive" --strip-components=1 -C "$HOME/.local/bin" "git-cliff-$CLIFF_VERSION/git-cliff"
|
||||
rm "$archive"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Tag a merged release, or update the release pull request
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
host="${GITHUB_SERVER_URL#https://}"
|
||||
git config user.name release-bot
|
||||
git config user.email "release-bot@${host#git.}"
|
||||
basic=$(printf 'release-bot:%s' "$TOKEN" | base64 -w0)
|
||||
push() { git -c http.extraHeader="Authorization: Basic $basic" push "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$@"; }
|
||||
gitea() { curl -fsS -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||
|
||||
# The release pull request was merged: tag its commit and publish the release.
|
||||
if [[ "$(git log -1 --format=%s)" =~ ^chore\(release\):\ (v[0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "$tag exists already"; exit 0; fi
|
||||
git tag "$tag"
|
||||
push "refs/tags/$tag"
|
||||
jq -n --arg tag "$tag" --arg body "$(git cliff --latest --strip all)" '{tag_name: $tag, name: $tag, body: $body}' |
|
||||
gitea -o /dev/null -X POST "$api/releases" -d @-
|
||||
echo "Released $tag"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise: anything to release since the last tag? (Nothing printed when no commit counts.)
|
||||
last=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
|
||||
next=$(git cliff --bumped-version 2>/dev/null || true)
|
||||
next="v${next#v}"
|
||||
if [ "$next" = v ] || [ "$next" = "$last" ]; then echo "Nothing to release since ${last:-the start}"; exit 0; fi
|
||||
|
||||
notes=$(git cliff --unreleased --bump --strip all)
|
||||
git switch -C release
|
||||
git cliff --bump --output CHANGELOG.md
|
||||
git add CHANGELOG.md
|
||||
git commit -q -m "chore(release): $next"
|
||||
push --force HEAD:refs/heads/release
|
||||
|
||||
title="chore(release): $next"
|
||||
body=$(printf 'Merging this releases **%s**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.\n\n%s\n' "$next" "$notes")
|
||||
payload=$(jq -n --arg title "$title" --arg body "$body" '{title: $title, body: $body}')
|
||||
open=$(gitea "$api/pulls?state=open&limit=50" | jq -r '[.[] | select(.head.ref == "release")][0].number // empty')
|
||||
if [ -n "$open" ]; then
|
||||
gitea -o /dev/null -X PATCH "$api/pulls/$open" -d "$payload"
|
||||
echo "Updated the release pull request #$open: $title"
|
||||
else
|
||||
jq '. + {head: "release", base: "main"}' <<<"$payload" | gitea -o /dev/null -X POST "$api/pulls" -d @-
|
||||
echo "Opened the release pull request: $title"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
|
||||
|
||||
## 0.2.0 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **web:** Download link to t5de.dev in the header (#2)
|
||||
|
||||
|
||||
## 0.1.0 (2026-11-02)
|
||||
|
||||
### Badges
|
||||
|
||||
- The proxy asks which IMVU accounts show the T5 badge
|
||||
|
||||
### CI
|
||||
|
||||
- Test against a Postgres service (embedded Postgres won't run as root)
|
||||
|
||||
### Design
|
||||
|
||||
- T5 products in the client's shop and inventory
|
||||
- Everywhere the T5 badge appears
|
||||
|
||||
### Hub
|
||||
|
||||
- Accounts, IMVU linking by tagline, proxy sign-in, presence, console
|
||||
|
||||
### Presence
|
||||
|
||||
- Count an account as soon as it's linked; accept an empty outfit
|
||||
|
||||
### Products
|
||||
|
||||
- Images need https addresses; lowering a rating waits for review; library shows creators
|
||||
- Carry the IMVU parent's image and categories
|
||||
|
||||
### Website
|
||||
|
||||
- Next.js app for accounts, linking, who's online, device sign-in and the console
|
||||
- Products, library, catalogs and their console page
|
||||
|
||||
@@ -10,6 +10,13 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
|
||||
in our own words are fine.
|
||||
- Test with a throwaway IMVU account, never a real one.
|
||||
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
|
||||
- Changes reach main only through pull requests, squash-merged: the PR's title becomes the commit on main, so it must
|
||||
be a conventional commit (`feat(api): …`, `fix(web): …`, `docs: …`; `!` before the colon for a breaking change),
|
||||
checked by `.gitea/workflows/pull-request.yml`. Commits on the branch can be anything. Push a branch and open the
|
||||
pull request on git.t5de.dev (web UI or API); the user merges.
|
||||
- Releases: release-bot keeps a `chore(release): vX.Y.Z` pull request open (`release.yml`, git-cliff from those
|
||||
titles); merging it tags vX.Y.Z, which builds the image and deploys prod. Never tag by hand (v* tags are
|
||||
release-bot's).
|
||||
- IMVU session keys never leave the user's machine; the hub never asks for them.
|
||||
|
||||
## Notes
|
||||
@@ -26,4 +33,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
|
||||
(`t5de-proxy...`).
|
||||
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
|
||||
`bunx`: use `bun x`).
|
||||
- Calls to IMVU (`apps/api/src/imvu.ts`, its public user API) are rare and rate-limited: linking and age re-checks.
|
||||
- Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms,
|
||||
and the IMVU products T5 products derive from (`parent_info`, looked up once per revision).
|
||||
@@ -49,4 +49,7 @@ Needs Bun (1.4) and Node (for Next.js).
|
||||
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
|
||||
public address) and `WEB_URL` (the website's).
|
||||
|
||||
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
|
||||
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
|
||||
|
||||
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
|
||||
+3
-1
@@ -5,7 +5,9 @@ COPY package.json bun.lock ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN bun install --frozen-lockfile --production --filter @t5de/api
|
||||
# Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated
|
||||
# layout keeps links in each package's own node_modules.
|
||||
RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted
|
||||
|
||||
FROM oven/bun:1.4.4-slim
|
||||
WORKDIR /app
|
||||
|
||||
+547
-10
@@ -2,7 +2,22 @@
|
||||
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
|
||||
// token.
|
||||
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 { AgeError, AgeService } from "./age";
|
||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||
@@ -11,6 +26,20 @@ import { Realtime } from "./realtime";
|
||||
import { isAdultRoom, Rooms } from "./rooms";
|
||||
import { Signer } from "./keys";
|
||||
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";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||
|
||||
@@ -76,8 +105,11 @@ class Limiter {
|
||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) {
|
||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||
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 hosted = new HostedRooms(db);
|
||||
const decorations = new Decorations(db);
|
||||
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
|
||||
const realtime =
|
||||
rt ??
|
||||
new Realtime({
|
||||
@@ -89,6 +121,23 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
room: (instanceId) => {
|
||||
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();
|
||||
|
||||
@@ -103,6 +152,30 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
banReason: p.banReason ?? "",
|
||||
});
|
||||
|
||||
/** 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. */
|
||||
const linkViews = async (userId: string, links: Link[]) => {
|
||||
const ids = links.map((l) => l.imvuId);
|
||||
@@ -178,10 +251,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 = (
|
||||
p: typeof schema.product.$inferSelect,
|
||||
creator?: string | null,
|
||||
creatorUsername?: string | null,
|
||||
latest?: { parent: number | null; parentInfo: ParentInfo | null } | null,
|
||||
) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -190,6 +265,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
latest: p.latest,
|
||||
creator: creator ?? creatorUsername ?? "",
|
||||
creatorUsername: creatorUsername ?? "",
|
||||
parent: latest?.parent ?? 0,
|
||||
image: latest?.parentInfo?.image ?? null,
|
||||
categories: latest?.parentInfo?.categories ?? [],
|
||||
deleted: p.deletedAt !== null,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -205,6 +284,126 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
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 ownLink = async (p: Person, imvuId: number) => {
|
||||
@@ -269,7 +468,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
})
|
||||
|
||||
// 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) } }), { signedIn: true })
|
||||
.get("/home", async ({ person }) => {
|
||||
const online = (await visibleOnline(person)).length;
|
||||
if (!person) return { onlineCount: online };
|
||||
@@ -560,6 +759,292 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
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.
|
||||
.post(
|
||||
"/catalogs",
|
||||
@@ -616,10 +1101,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
"/products",
|
||||
async ({ person, query }) => {
|
||||
const adult = await personAdult(person);
|
||||
const rows = await products.list({ q: query.q, creator: query.creator, adult });
|
||||
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) };
|
||||
const rows = await products.list({ ...query, adult });
|
||||
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(
|
||||
"/products/:id",
|
||||
@@ -627,8 +1119,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
const adult = await personAdult(person);
|
||||
const p = await productCall(() => products.get(params.id, adult));
|
||||
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
|
||||
const latest = p.revisions.find((r) => r.number === p.product.latest);
|
||||
return {
|
||||
product: productView(p.product, p.creator, p.creatorUsername),
|
||||
product: productView(p.product, p.creator, p.creatorUsername, latest),
|
||||
revisions: p.revisions.map((r) => ({
|
||||
number: r.number,
|
||||
rating: r.rating,
|
||||
@@ -651,7 +1144,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
async ({ me, params, body }) => {
|
||||
const p = await productCall(() => products.update(me.id, params.id, 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,
|
||||
@@ -663,6 +1156,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(
|
||||
"/products/:id/revisions/:n/retract",
|
||||
async ({ me, params }) => {
|
||||
@@ -704,7 +1217,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
"/library",
|
||||
async ({ me }) => ({
|
||||
library: (await products.library(me.id)).map((l) => ({
|
||||
product: productView(l.product, l.creator),
|
||||
product: productView(l.product, l.creator, undefined, l),
|
||||
stayOn: l.stayOn,
|
||||
addedAt: l.addedAt.toISOString(),
|
||||
})),
|
||||
@@ -944,6 +1457,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.
|
||||
.ws("/realtime", {
|
||||
async beforeHandle({ person, set }) {
|
||||
@@ -978,7 +1515,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
.get("/health", () => ({ ok: true }))
|
||||
.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"];
|
||||
@@ -0,0 +1,109 @@
|
||||
// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
|
||||
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
|
||||
// room is realtime.ts's decorated().
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
|
||||
import { hostedBase } from "./realtime";
|
||||
|
||||
const { roomDecoration, product, productRevision, imvuLink } = schema;
|
||||
|
||||
export const maxDecorations = 300;
|
||||
export const maxDecorationPlacement = 200;
|
||||
export const maxBatch = 20;
|
||||
|
||||
/** An IMVU room instance: "<owner's customer ID>-<n>". */
|
||||
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
|
||||
|
||||
/** An error for the person; the routes turn it into a Problem. */
|
||||
export class DecorationError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly slug: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const decorationHash = (contents: DecorationItem[]) =>
|
||||
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
|
||||
|
||||
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
|
||||
function ownerOf(instance: string): number {
|
||||
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
|
||||
const owner = Number(instance.slice(0, instance.indexOf("-")));
|
||||
if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase)
|
||||
throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
|
||||
return owner;
|
||||
}
|
||||
|
||||
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
|
||||
|
||||
export class Decorations {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
|
||||
* retracted, and general unless the viewer counts as adult.
|
||||
*/
|
||||
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
|
||||
if (ids.length === 0) return new Set();
|
||||
const rows = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(
|
||||
and(
|
||||
inArray(product.id, ids),
|
||||
ne(product.latest, 0),
|
||||
isNull(product.removedAt),
|
||||
adult ? undefined : eq(product.rating, "general"),
|
||||
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
|
||||
),
|
||||
);
|
||||
return new Set(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
|
||||
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
|
||||
for (const i of instances) ownerOf(i);
|
||||
const rows = instances.length
|
||||
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
|
||||
: [];
|
||||
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
|
||||
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
|
||||
return instances.map((instance) => {
|
||||
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
|
||||
return { instance, contents, hash: decorationHash(contents) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
|
||||
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
|
||||
const owner = ownerOf(instance);
|
||||
const [link] = await this.db
|
||||
.select()
|
||||
.from(imvuLink)
|
||||
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
|
||||
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
|
||||
if (contents.length > maxDecorations)
|
||||
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
|
||||
if (new Set(contents.map((it) => it[0])).size !== contents.length)
|
||||
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
|
||||
const ids = [...new Set(contents.map((it) => it[1]))];
|
||||
if (ids.length) {
|
||||
const known = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
|
||||
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
|
||||
}
|
||||
if (contents.length === 0) {
|
||||
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
|
||||
return;
|
||||
}
|
||||
await this.db
|
||||
.insert(roomDecoration)
|
||||
.values({ instanceId: instance, ownerImvuId: owner, contents })
|
||||
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// T5DE-hosted rooms (docs/hosted-rooms.md): made on the website, joined through T5DE's proxy. The routes are in
|
||||
// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves, and (phase 2) their moderators, bans,
|
||||
// images, and people's favorites, ratings and visits.
|
||||
//
|
||||
// A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they
|
||||
// aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
avg,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
inArray,
|
||||
isNull,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
schema,
|
||||
type Database,
|
||||
type RoomModerator,
|
||||
type SceneItem,
|
||||
} from "@t5de/db";
|
||||
import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
|
||||
|
||||
const { hostedRoom, hostedRoomBan, hostedRoomFavorite, hostedRoomImage, hostedRoomRating, hostedRoomVisit, imvuLink, user } =
|
||||
schema;
|
||||
|
||||
export type HostedRoom = typeof hostedRoom.$inferSelect;
|
||||
export type Rating = "general" | "adult";
|
||||
export type Privacy = "public" | "private";
|
||||
|
||||
/** An error for the person; the routes turn it into a Problem. */
|
||||
export class HostedError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly slug: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const maxRoomsPerPerson = 10;
|
||||
export const maxSceneItems = 500;
|
||||
export const maxPlacement = 200;
|
||||
export const maxModerators = 20;
|
||||
export const maxImageBytes = 512 * 1024;
|
||||
const visitsKept = 50;
|
||||
const recentShown = 20;
|
||||
|
||||
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
|
||||
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
|
||||
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
|
||||
|
||||
// "<owner's IMVU ID>-<hostedBase + n>": the client takes a room's owner from the first part (so the owner can
|
||||
// decorate it), and IMVU's own room numbers are far below hostedBase.
|
||||
export const instanceId = (owner: number, n: number) => `${owner}-${hostedBase + n}`;
|
||||
export const chatId = (n: number) => hostedBase + n;
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
|
||||
|
||||
function cleanName(s: string): string {
|
||||
const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim();
|
||||
if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters.");
|
||||
return v;
|
||||
}
|
||||
|
||||
function cleanDescription(s: string): string {
|
||||
const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim();
|
||||
if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters.");
|
||||
return v;
|
||||
}
|
||||
|
||||
/** An image's content type by its magic bytes: PNG or JPEG only. */
|
||||
export function imageType(b: Uint8Array): "image/png" | "image/jpeg" | undefined {
|
||||
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
if (b.length > 24 && png.every((v, i) => b[i] === v)) return "image/png";
|
||||
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type NewRoom = {
|
||||
name: string;
|
||||
description?: string;
|
||||
roomPid: number;
|
||||
rating: Rating;
|
||||
maxUsers?: number;
|
||||
imvuId: number;
|
||||
privacy?: Privacy;
|
||||
};
|
||||
|
||||
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers" | "privacy">> & {
|
||||
/** Already resolved to accounts (app.ts resolves names). */
|
||||
moderators?: RoomModerator[];
|
||||
};
|
||||
|
||||
type Row = { room: HostedRoom; ownerName: string };
|
||||
|
||||
/** Who's looking: the person and their linked IMVU accounts (for private rooms they moderate). */
|
||||
export type Viewer = { id: string; imvuIds: number[] };
|
||||
|
||||
export const isRoomModerator = (r: HostedRoom, imvuIds: Iterable<number>) => {
|
||||
const ids = new Set(imvuIds);
|
||||
return r.moderators.some((m) => ids.has(m.imvuId));
|
||||
};
|
||||
|
||||
export class HostedRooms {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
// Rooms that are shown: the owner account still linked to the owner, the owner not banned.
|
||||
private shown() {
|
||||
return this.db
|
||||
.select({ room: hostedRoom, ownerName: imvuLink.username })
|
||||
.from(hostedRoom)
|
||||
.innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId)))
|
||||
.innerJoin(user, eq(user.id, hostedRoom.ownerId))
|
||||
.$dynamic();
|
||||
}
|
||||
|
||||
private visible = sql`coalesce(${user.banned}, false) = false`;
|
||||
|
||||
/** Public rooms, and private ones the viewer owns or moderates. */
|
||||
private listable(viewer?: Viewer) {
|
||||
if (!viewer) return eq(hostedRoom.privacy, "public");
|
||||
const mods = viewer.imvuIds.length
|
||||
? sql`exists (select 1 from jsonb_array_elements(${hostedRoom.moderators}) m where (m->>'imvuId')::bigint in (${sql.join(
|
||||
viewer.imvuIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)}))`
|
||||
: undefined;
|
||||
return or(eq(hostedRoom.privacy, "public"), eq(hostedRoom.ownerId, viewer.id), mods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open rooms the viewer may see: listed ones (public, and private ones they own or moderate), newest first; or
|
||||
* (mine) their own; (favorites) their favorites, latest first; (recent) the last 20 they joined, latest first.
|
||||
*/
|
||||
async list({
|
||||
adult,
|
||||
viewer,
|
||||
mine,
|
||||
favorites,
|
||||
recent,
|
||||
}: {
|
||||
adult: boolean;
|
||||
viewer?: Viewer;
|
||||
mine?: boolean;
|
||||
favorites?: boolean;
|
||||
recent?: boolean;
|
||||
}): Promise<Row[]> {
|
||||
const open = and(this.visible, eq(hostedRoom.closed, false), adult || mine ? undefined : eq(hostedRoom.rating, "general"));
|
||||
if (viewer && favorites) {
|
||||
return this.shown()
|
||||
.innerJoin(hostedRoomFavorite, and(eq(hostedRoomFavorite.n, hostedRoom.n), eq(hostedRoomFavorite.userId, viewer.id)))
|
||||
.where(open)
|
||||
.orderBy(desc(hostedRoomFavorite.createdAt), desc(hostedRoom.n))
|
||||
.limit(200);
|
||||
}
|
||||
if (viewer && recent) {
|
||||
return this.shown()
|
||||
.innerJoin(hostedRoomVisit, and(eq(hostedRoomVisit.n, hostedRoom.n), eq(hostedRoomVisit.userId, viewer.id)))
|
||||
.where(open)
|
||||
.orderBy(desc(hostedRoomVisit.at))
|
||||
.limit(recentShown);
|
||||
}
|
||||
return this.shown()
|
||||
.where(and(open, mine && viewer ? eq(hostedRoom.ownerId, viewer.id) : this.listable(viewer)))
|
||||
.orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n))
|
||||
.limit(200);
|
||||
}
|
||||
|
||||
/** A room as shown to a viewer; closed rooms are 410, hidden and adult ones (for others) 404. */
|
||||
async get(n: number, adult: boolean, viewerId?: string): Promise<Row> {
|
||||
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
|
||||
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
|
||||
if (!r) throw new HostedError(404, "not_found", "No such room.");
|
||||
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
|
||||
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
|
||||
throw new HostedError(404, "not_found", "No such room.");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
|
||||
async info(n: number): Promise<HostedRoomInfo | undefined> {
|
||||
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
|
||||
return r ? roomInfo(r) : undefined;
|
||||
}
|
||||
|
||||
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
|
||||
const name = cleanName(body.name);
|
||||
const description = cleanDescription(body.description ?? "");
|
||||
if (body.rating === "adult" && !adultPerson)
|
||||
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
|
||||
const [link] = await this.db
|
||||
.select()
|
||||
.from(imvuLink)
|
||||
.where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId)));
|
||||
if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you.");
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [{ n } = { n: 0 }] = await tx
|
||||
.select({ n: count() })
|
||||
.from(hostedRoom)
|
||||
.where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false)));
|
||||
if (n >= maxRoomsPerPerson)
|
||||
throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`);
|
||||
const [r] = await tx
|
||||
.insert(hostedRoom)
|
||||
.values({
|
||||
ownerId,
|
||||
ownerImvuId: link.imvuId,
|
||||
name,
|
||||
description,
|
||||
roomPid: body.roomPid,
|
||||
rating: body.rating,
|
||||
maxUsers: body.maxUsers ?? 10,
|
||||
privacy: body.privacy ?? "public",
|
||||
scene: [],
|
||||
sceneHash: sceneHash(body.roomPid, []),
|
||||
})
|
||||
.returning();
|
||||
return { room: r!, ownerName: link.username };
|
||||
});
|
||||
}
|
||||
|
||||
/** The owner's room, open (for changes). */
|
||||
async own(ownerId: string, n: number) {
|
||||
const r = await this.get(n, true, ownerId);
|
||||
if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
|
||||
return r;
|
||||
}
|
||||
|
||||
async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) {
|
||||
const { room: r, ownerName } = await this.own(ownerId, n);
|
||||
if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson)
|
||||
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
|
||||
const roomPid = changes.roomPid ?? r.roomPid;
|
||||
let moderators: RoomModerator[] | undefined;
|
||||
if (changes.moderators) {
|
||||
const seen = new Set<number>([r.ownerImvuId]); // the owner isn't listed as a moderator of their own room
|
||||
moderators = changes.moderators.filter((m) => !seen.has(m.imvuId) && seen.add(m.imvuId));
|
||||
if (moderators.length > maxModerators)
|
||||
throw new HostedError(400, "invalid", `A room has up to ${maxModerators} moderators.`);
|
||||
}
|
||||
const [updated] = await this.db
|
||||
.update(hostedRoom)
|
||||
.set({
|
||||
...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}),
|
||||
...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}),
|
||||
...(changes.rating !== undefined ? { rating: changes.rating } : {}),
|
||||
...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}),
|
||||
...(changes.privacy !== undefined ? { privacy: changes.privacy } : {}),
|
||||
...(moderators !== undefined ? { moderators } : {}),
|
||||
...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}),
|
||||
})
|
||||
.where(eq(hostedRoom.n, n))
|
||||
.returning();
|
||||
return { room: updated!, ownerName };
|
||||
}
|
||||
|
||||
async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) {
|
||||
const { room: r, ownerName } = await this.own(ownerId, n);
|
||||
const pid = roomPid ?? r.roomPid;
|
||||
const [updated] = await this.db
|
||||
.update(hostedRoom)
|
||||
.set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) })
|
||||
.where(eq(hostedRoom.n, n))
|
||||
.returning();
|
||||
return { room: updated!, ownerName };
|
||||
}
|
||||
|
||||
/** Closes a room: by its owner, or (moderator) anyone's. */
|
||||
async close(actorId: string, moderator: boolean, n: number) {
|
||||
const { room: r } = await this.get(n, true, actorId);
|
||||
if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
|
||||
await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n));
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- Bans ----
|
||||
|
||||
/** A room the viewer owns or moderates (for its bans). */
|
||||
async managed(viewer: Viewer, n: number) {
|
||||
const r = await this.get(n, true, viewer.id);
|
||||
if (r.room.ownerId !== viewer.id && !isRoomModerator(r.room, viewer.imvuIds))
|
||||
throw new HostedError(403, "forbidden", "Only the room's owner and moderators can do this.");
|
||||
return r;
|
||||
}
|
||||
|
||||
async ban(n: number, imvuId: number, byImvuId: number, minutes: number) {
|
||||
const until = minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
|
||||
await this.db
|
||||
.insert(hostedRoomBan)
|
||||
.values({ n, imvuId, byImvuId, until })
|
||||
.onConflictDoUpdate({ target: [hostedRoomBan.n, hostedRoomBan.imvuId], set: { byImvuId, until, createdAt: new Date() } });
|
||||
}
|
||||
|
||||
private inForce = () => or(isNull(hostedRoomBan.until), gt(hostedRoomBan.until, new Date()));
|
||||
|
||||
async banned(n: number, imvuId: number): Promise<boolean> {
|
||||
const [b] = await this.db
|
||||
.select({ n: hostedRoomBan.n })
|
||||
.from(hostedRoomBan)
|
||||
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId), this.inForce()));
|
||||
return b !== undefined;
|
||||
}
|
||||
|
||||
/** A room's bans in force, latest first. */
|
||||
async bans(n: number) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(hostedRoomBan)
|
||||
.where(and(eq(hostedRoomBan.n, n), this.inForce()))
|
||||
.orderBy(desc(hostedRoomBan.createdAt));
|
||||
}
|
||||
|
||||
/** Lifts a ban (owner only); false if there was none. */
|
||||
async unban(ownerId: string, n: number, imvuId: number): Promise<boolean> {
|
||||
await this.own(ownerId, n);
|
||||
const gone = await this.db
|
||||
.delete(hostedRoomBan)
|
||||
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId)))
|
||||
.returning();
|
||||
return gone.length > 0;
|
||||
}
|
||||
|
||||
// ---- Images ----
|
||||
|
||||
async setImage(ownerId: string, n: number, data: Buffer) {
|
||||
const { ownerName } = await this.own(ownerId, n);
|
||||
if (data.length > maxImageBytes) throw new HostedError(400, "invalid", "An image is up to 512 KB.");
|
||||
const contentType = imageType(data);
|
||||
if (!contentType) throw new HostedError(400, "invalid", "An image is a PNG or JPEG.");
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
const room = await this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(hostedRoomImage)
|
||||
.values({ n, contentType, data, hash })
|
||||
.onConflictDoUpdate({ target: hostedRoomImage.n, set: { contentType, data, hash, updatedAt: new Date() } });
|
||||
const [r] = await tx.update(hostedRoom).set({ imageHash: hash }).where(eq(hostedRoom.n, n)).returning();
|
||||
return r!;
|
||||
});
|
||||
return { room, ownerName };
|
||||
}
|
||||
|
||||
async removeImage(ownerId: string, n: number) {
|
||||
const { ownerName } = await this.own(ownerId, n);
|
||||
await this.db.delete(hostedRoomImage).where(eq(hostedRoomImage.n, n));
|
||||
const [room] = await this.db.update(hostedRoom).set({ imageHash: null }).where(eq(hostedRoom.n, n)).returning();
|
||||
return { room: room!, ownerName };
|
||||
}
|
||||
|
||||
async image(n: number) {
|
||||
const [i] = await this.db.select().from(hostedRoomImage).where(eq(hostedRoomImage.n, n));
|
||||
return i;
|
||||
}
|
||||
|
||||
// ---- Favorites, ratings, visits ----
|
||||
|
||||
async favorite(userId: string, n: number, on: boolean) {
|
||||
if (on) await this.db.insert(hostedRoomFavorite).values({ userId, n }).onConflictDoNothing();
|
||||
else await this.db.delete(hostedRoomFavorite).where(and(eq(hostedRoomFavorite.userId, userId), eq(hostedRoomFavorite.n, n)));
|
||||
}
|
||||
|
||||
/** Which of these rooms are the person's favorites. */
|
||||
async favoriteSet(userId: string, ns: number[]): Promise<Set<number>> {
|
||||
if (ns.length === 0) return new Set();
|
||||
const rows = await this.db
|
||||
.select({ n: hostedRoomFavorite.n })
|
||||
.from(hostedRoomFavorite)
|
||||
.where(and(eq(hostedRoomFavorite.userId, userId), inArray(hostedRoomFavorite.n, ns)));
|
||||
return new Set(rows.map((r) => r.n));
|
||||
}
|
||||
|
||||
/** Sets (1-5) or removes (null) the person's rating. */
|
||||
async rate(userId: string, n: number, score: number | null) {
|
||||
if (score === null) {
|
||||
await this.db.delete(hostedRoomRating).where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
|
||||
return;
|
||||
}
|
||||
await this.db
|
||||
.insert(hostedRoomRating)
|
||||
.values({ userId, n, score })
|
||||
.onConflictDoUpdate({ target: [hostedRoomRating.userId, hostedRoomRating.n], set: { score, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
async myRating(userId: string, n: number): Promise<number> {
|
||||
const [r] = await this.db
|
||||
.select({ score: hostedRoomRating.score })
|
||||
.from(hostedRoomRating)
|
||||
.where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
|
||||
return r?.score ?? 0;
|
||||
}
|
||||
|
||||
/** Each room's average rating (0 if none, two decimals) and how many rated it. */
|
||||
async scores(ns: number[]): Promise<Map<number, { score: number; ratings: number }>> {
|
||||
if (ns.length === 0) return new Map();
|
||||
const rows = await this.db
|
||||
.select({ n: hostedRoomRating.n, score: avg(hostedRoomRating.score), ratings: count() })
|
||||
.from(hostedRoomRating)
|
||||
.where(inArray(hostedRoomRating.n, ns))
|
||||
.groupBy(hostedRoomRating.n);
|
||||
return new Map(rows.map((r) => [r.n, { score: Math.round(Number(r.score ?? 0) * 100) / 100, ratings: r.ratings }]));
|
||||
}
|
||||
|
||||
/** Records a visit (a join), keeping the person's latest 50. */
|
||||
async visit(userId: string, n: number, imvuId: number) {
|
||||
const at = new Date();
|
||||
await this.db
|
||||
.insert(hostedRoomVisit)
|
||||
.values({ userId, n, imvuId, at })
|
||||
.onConflictDoUpdate({ target: [hostedRoomVisit.userId, hostedRoomVisit.n], set: { imvuId, at } });
|
||||
const keep = this.db
|
||||
.select({ n: hostedRoomVisit.n })
|
||||
.from(hostedRoomVisit)
|
||||
.where(eq(hostedRoomVisit.userId, userId))
|
||||
.orderBy(desc(hostedRoomVisit.at))
|
||||
.limit(visitsKept);
|
||||
await this.db.delete(hostedRoomVisit).where(and(eq(hostedRoomVisit.userId, userId), notInArray(hostedRoomVisit.n, keep)));
|
||||
}
|
||||
}
|
||||
|
||||
/** What realtime needs of a room. */
|
||||
export function roomInfo({ room: r, ownerName }: Row): HostedRoomInfo {
|
||||
return {
|
||||
closed: r.closed,
|
||||
adult: r.rating === "adult",
|
||||
maxUsers: r.maxUsers,
|
||||
privacy: r.privacy === "private" ? "private" : "public",
|
||||
ownerImvuId: r.ownerImvuId,
|
||||
moderators: r.moderators.map((m) => m.imvuId),
|
||||
name: r.name,
|
||||
ownerName,
|
||||
};
|
||||
}
|
||||
+58
-4
@@ -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;
|
||||
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends
|
||||
// on it for nothing else.
|
||||
// rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
|
||||
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
|
||||
|
||||
export type ImvuUser = {
|
||||
id: number;
|
||||
@@ -8,6 +8,7 @@ export type ImvuUser = {
|
||||
tagline: string;
|
||||
ageVerified: boolean;
|
||||
accessPass: boolean;
|
||||
picture: string | null; // the avatar's picture (https, on *.imvu.com)
|
||||
};
|
||||
|
||||
export type ImvuRoom = {
|
||||
@@ -25,6 +26,13 @@ export type ImvuRoom = {
|
||||
expired: boolean;
|
||||
};
|
||||
|
||||
/** An IMVU product's public info, as T5 products derived from it show it. */
|
||||
export type ImvuProduct = {
|
||||
image: string | null; // https, on *.imvu.com
|
||||
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
|
||||
gender: string; // "female", "male", or "" (either)
|
||||
};
|
||||
|
||||
export class NoSuchImvuUser extends Error {
|
||||
constructor() {
|
||||
super("no such IMVU account");
|
||||
@@ -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() {
|
||||
const now = Date.now();
|
||||
const at = Math.max(now, this.next);
|
||||
@@ -72,7 +94,8 @@ export class ImvuClient {
|
||||
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();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -83,7 +106,7 @@ export class ImvuClient {
|
||||
} catch (e) {
|
||||
throw new ImvuUnavailable(String(e));
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return 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.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
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 : "",
|
||||
ageVerified: data.is_ageverified === true,
|
||||
accessPass: data.is_ap === true,
|
||||
picture: imvuImage(data.thumbnail_url),
|
||||
};
|
||||
}
|
||||
@@ -22,12 +22,16 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
||||
|
||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||
|
||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
|
||||
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
|
||||
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
|
||||
parentInfo();
|
||||
setInterval(() => {
|
||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||
parentInfo();
|
||||
}, 10 * 60 * 1000);
|
||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||
+141
-10
@@ -1,7 +1,23 @@
|
||||
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { and, desc, eq, 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 type { ImvuClient } from "./imvu";
|
||||
import type { Signer } from "./keys";
|
||||
|
||||
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[]) =>
|
||||
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
|
||||
|
||||
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
|
||||
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Each product's latest revision (joined on product.latest, for lists). */
|
||||
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
|
||||
|
||||
export class Products {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly signer: Signer,
|
||||
private readonly imvu?: ImvuClient,
|
||||
) {}
|
||||
|
||||
// ---- catalogs ----
|
||||
@@ -164,7 +187,7 @@ export class Products {
|
||||
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
||||
const personId = claims.sub;
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
const made = await this.db.transaction(async (tx) => {
|
||||
let p: Product;
|
||||
if (typeof claims.product === "string") {
|
||||
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
|
||||
@@ -209,6 +232,62 @@ export class Products {
|
||||
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
|
||||
return { product: p.id, revision: number };
|
||||
});
|
||||
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
|
||||
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
|
||||
return made;
|
||||
}
|
||||
|
||||
// ---- parents (the IMVU products revisions derive from) ----
|
||||
|
||||
/** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */
|
||||
async lookUpParent(productId: string, number: number, parent: number): Promise<ParentInfo | undefined> {
|
||||
if (!this.imvu || parent <= 0) return undefined;
|
||||
const since = new Date(Date.now() - parentInfoFresh).toISOString();
|
||||
const [known] = await this.db
|
||||
.select({ info: productRevision.parentInfo })
|
||||
.from(productRevision)
|
||||
.where(
|
||||
and(
|
||||
eq(productRevision.parent, parent),
|
||||
sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
let info = known?.info ?? undefined;
|
||||
if (!info) {
|
||||
const p = await this.imvu.product(parent);
|
||||
info = {
|
||||
found: p !== null,
|
||||
image: p?.image ?? null,
|
||||
categories: p?.categories ?? [],
|
||||
gender: p?.gender ?? "",
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
await this.db
|
||||
.update(productRevision)
|
||||
.set({ parentInfo: info })
|
||||
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */
|
||||
async fillParentInfo(limit = 20): Promise<number> {
|
||||
const due = await this.db
|
||||
.select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent })
|
||||
.from(productRevision)
|
||||
.where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo)))
|
||||
.orderBy(desc(productRevision.createdAt))
|
||||
.limit(limit);
|
||||
let n = 0;
|
||||
for (const r of due) {
|
||||
try {
|
||||
if (await this.lookUpParent(r.productId, r.number, r.parent)) n++;
|
||||
} catch {
|
||||
// IMVU unavailable: try again next time.
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- products ----
|
||||
@@ -217,22 +296,63 @@ export class Products {
|
||||
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
|
||||
if (!p) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
|
||||
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Products, newest first; adult ones only if adult. */
|
||||
async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0)];
|
||||
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
|
||||
async delete(personId: string, id: string) {
|
||||
await this.own(personId, id);
|
||||
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
|
||||
}
|
||||
|
||||
/** Moderation removes a product: gone everywhere. */
|
||||
async removeByModeration(id: string, reason: string) {
|
||||
const [p] = await this.db
|
||||
.update(product)
|
||||
.set({ removedAt: new Date(), removedReason: reason })
|
||||
.where(and(eq(product.id, id), isNull(product.removedAt)))
|
||||
.returning();
|
||||
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
|
||||
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
||||
*/
|
||||
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
|
||||
if (!opts.adult) conds.push(eq(product.rating, "general"));
|
||||
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
|
||||
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
|
||||
if (opts.category !== undefined)
|
||||
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
|
||||
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
|
||||
return this.db
|
||||
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
|
||||
.select({
|
||||
product,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
})
|
||||
.from(product)
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(product.createdAt))
|
||||
.limit(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) {
|
||||
@@ -242,6 +362,7 @@ export class Products {
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(product.id, id));
|
||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
const revisions = await this.db
|
||||
.select()
|
||||
@@ -294,11 +415,19 @@ export class Products {
|
||||
|
||||
async library(personId: string) {
|
||||
return this.db
|
||||
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername })
|
||||
.select({
|
||||
product,
|
||||
stayOn: library.stayOn,
|
||||
addedAt: library.addedAt,
|
||||
creator: user.displayUsername,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
})
|
||||
.from(library)
|
||||
.innerJoin(product, eq(product.id, library.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(library.userId, personId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -311,7 +440,9 @@ export class Products {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+475
-4
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// community products).
|
||||
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
|
||||
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
|
||||
//
|
||||
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
||||
// a bearer token):
|
||||
@@ -12,14 +12,70 @@
|
||||
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
|
||||
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
|
||||
// products only go to clients whose IMVU account counts as adult.
|
||||
// hub -> proxy {"type": "decor", "room": "1-2"}
|
||||
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
|
||||
// with a chat in that room
|
||||
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
||||
//
|
||||
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
|
||||
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
|
||||
// proxy -> hub {"type": "room.leave", "room": n}
|
||||
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
|
||||
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
|
||||
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
|
||||
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
|
||||
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}],
|
||||
// "sequence": s} (the latest message number)
|
||||
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
|
||||
// "not_linked" | "too_many", "message": "..."}
|
||||
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
|
||||
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
|
||||
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
|
||||
// was unlinked or changed, or the room became adult)
|
||||
//
|
||||
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
|
||||
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
|
||||
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
|
||||
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
|
||||
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
|
||||
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
|
||||
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
|
||||
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
|
||||
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
|
||||
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
|
||||
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
|
||||
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
|
||||
|
||||
export type Item = { id: string; name: string; adult: boolean };
|
||||
|
||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
||||
|
||||
/** What realtime needs to know of a hosted room to let someone in. */
|
||||
export type HostedRoomInfo = {
|
||||
closed: boolean;
|
||||
adult: boolean;
|
||||
maxUsers: number;
|
||||
privacy: "public" | "private";
|
||||
ownerImvuId: number;
|
||||
moderators: number[];
|
||||
name: string;
|
||||
ownerName: string;
|
||||
};
|
||||
|
||||
/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
|
||||
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
|
||||
|
||||
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
|
||||
export const hostedBase = 2_100_000_000;
|
||||
/** The highest room number: chat IDs must stay XML-RPC integers. */
|
||||
export const maxHostedN = 2_147_483_647 - hostedBase;
|
||||
|
||||
type Participant = { imvu_id: number; seat: number; name: string };
|
||||
|
||||
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
|
||||
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
|
||||
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it
|
||||
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
|
||||
type Session = {
|
||||
userId: string;
|
||||
imvuId: number;
|
||||
@@ -29,10 +85,25 @@ type Session = {
|
||||
outfit: Item[];
|
||||
chats: Map<string, string>;
|
||||
lastSent: Map<string, string>;
|
||||
hosted: Set<number>;
|
||||
tokens: number;
|
||||
refilled: number;
|
||||
watching: Set<number>;
|
||||
watchGen: number;
|
||||
invites: number;
|
||||
invitesRefilled: number;
|
||||
send: (message: object) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
|
||||
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
|
||||
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
|
||||
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
|
||||
|
||||
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
|
||||
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
|
||||
|
||||
export type RealtimeDeps = {
|
||||
/** The person an IMVU account is linked to. */
|
||||
owner: (imvuId: number) => Promise<string | undefined>;
|
||||
@@ -40,20 +111,76 @@ export type RealtimeDeps = {
|
||||
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
||||
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
||||
room?: (instanceId: string) => void;
|
||||
/** A hosted room (undefined: there's none by that number). */
|
||||
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
|
||||
/** Whether an IMVU account is banned from a hosted room now. */
|
||||
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
|
||||
/** Someone was booted (and maybe banned): store the ban, log it. */
|
||||
hostedBoot?: (boot: Boot) => Promise<void>;
|
||||
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
|
||||
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
|
||||
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
|
||||
accountName?: (imvuId: number) => Promise<string>;
|
||||
};
|
||||
|
||||
const maxChats = 16;
|
||||
const chatPattern = /^[0-9]{1,20}$/;
|
||||
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
||||
|
||||
const maxHostedPerSession = 5;
|
||||
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
|
||||
const sendBurst = 20;
|
||||
const sendPerSecond = 10;
|
||||
const maxWatched = 100;
|
||||
const inviteLifetime = 10 * 60 * 1000;
|
||||
const inviteBurst = 10;
|
||||
const invitesPerMinute = 10;
|
||||
const maxNote = 200;
|
||||
const maxBanMinutes = 10 * 366 * 24 * 60;
|
||||
|
||||
const roomErrors = {
|
||||
not_linked: "Sign in to T5DE with a linked IMVU account first.",
|
||||
not_found: "No such room.",
|
||||
closed: "This room is closed.",
|
||||
adult: "This room is for adults.",
|
||||
full: "This room is full.",
|
||||
too_many: "You're in too many rooms.",
|
||||
banned: "You're banned from this room.",
|
||||
private: "This room is private.",
|
||||
} as const;
|
||||
|
||||
// Reasons in room.boot_result and room.invite_result.
|
||||
const actionErrors = {
|
||||
not_linked: roomErrors.not_linked,
|
||||
not_found: roomErrors.not_found,
|
||||
closed: roomErrors.closed,
|
||||
forbidden: "You can't do that in this room.",
|
||||
not_in_room: "Join the room first.",
|
||||
banned: "They're banned from this room.",
|
||||
adult: "This room is for adults.",
|
||||
rate_limited: "Too many invitations. Try again in a minute.",
|
||||
} as const;
|
||||
|
||||
export class Realtime {
|
||||
private sessions = new Map<string, Session>();
|
||||
private hosted = new Map<number, Hosted>();
|
||||
private watchers = new Map<number, Set<string>>();
|
||||
private invites = new Map<string, Invite>();
|
||||
private listeners = new Set<() => void>();
|
||||
private readonly deps: Required<RealtimeDeps>;
|
||||
|
||||
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
||||
const d = typeof deps === "function" ? { owner: deps } : deps;
|
||||
this.deps = { adult: async () => false, room: () => {}, ...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. */
|
||||
@@ -94,6 +221,13 @@ export class Realtime {
|
||||
outfit: [],
|
||||
chats: new Map(),
|
||||
lastSent: new Map(),
|
||||
hosted: new Set(),
|
||||
tokens: sendBurst,
|
||||
refilled: Date.now(),
|
||||
watching: new Set(),
|
||||
watchGen: 0,
|
||||
invites: inviteBurst,
|
||||
invitesRefilled: Date.now(),
|
||||
send,
|
||||
close,
|
||||
});
|
||||
@@ -101,6 +235,10 @@ export class Realtime {
|
||||
|
||||
close(id: string) {
|
||||
const s = this.sessions.get(id);
|
||||
if (s) {
|
||||
this.leaveAllHosted(id, s, false);
|
||||
this.unwatch(id, s);
|
||||
}
|
||||
this.sessions.delete(id);
|
||||
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||
}
|
||||
@@ -125,8 +263,9 @@ export class Realtime {
|
||||
unlinked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const s of this.sessions.values()) {
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.userId === userId && s.imvuId === imvuId) {
|
||||
this.leaveAllHosted(id, s, true);
|
||||
s.imvuId = 0;
|
||||
s.adult = false;
|
||||
s.outfit = [];
|
||||
@@ -142,6 +281,12 @@ export class Realtime {
|
||||
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
|
||||
}
|
||||
|
||||
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
|
||||
decorated(instance: string) {
|
||||
const message = { type: "decor", room: instance };
|
||||
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
|
||||
}
|
||||
|
||||
/** Closes a person's connections (banned, or signed out of their computers). */
|
||||
disconnect(userId: string) {
|
||||
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
||||
@@ -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. */
|
||||
async message(id: string, raw: unknown): Promise<object | undefined> {
|
||||
const s = this.sessions.get(id);
|
||||
@@ -181,12 +608,23 @@ export class Realtime {
|
||||
items?: unknown;
|
||||
chat?: unknown;
|
||||
room?: unknown;
|
||||
seat?: unknown;
|
||||
mount?: unknown;
|
||||
message?: unknown;
|
||||
invite?: unknown;
|
||||
ban?: unknown;
|
||||
minutes?: unknown;
|
||||
accept?: unknown;
|
||||
note?: unknown;
|
||||
rooms?: unknown;
|
||||
};
|
||||
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
||||
if (m.type === "session") {
|
||||
const imvuId = Number(m.imvu_id);
|
||||
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
||||
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
|
||||
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
|
||||
if (owner !== s.userId) {
|
||||
// Not this person's account: no presence, no outfit for it.
|
||||
s.imvuId = 0;
|
||||
@@ -231,6 +669,39 @@ export class Realtime {
|
||||
this.changed([chat]);
|
||||
return;
|
||||
}
|
||||
if (m.type === "room.watch") return this.watch(id, s, m.rooms);
|
||||
if (m.type === "room.invite_reply") return this.inviteReply(s, m);
|
||||
if (
|
||||
m.type === "room.join" ||
|
||||
m.type === "room.leave" ||
|
||||
m.type === "room.seat" ||
|
||||
m.type === "room.send" ||
|
||||
m.type === "room.boot" ||
|
||||
m.type === "room.invite"
|
||||
) {
|
||||
const n = m.room;
|
||||
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" };
|
||||
if (m.type === "room.join") return this.joinHosted(id, s, n, m.invite);
|
||||
if (m.type === "room.boot") return this.bootHosted(id, s, n, m);
|
||||
if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id);
|
||||
if (m.type === "room.leave") {
|
||||
this.leaveHosted(id, s, n, false);
|
||||
// Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room.
|
||||
const room = this.hosted.get(n);
|
||||
if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId);
|
||||
return;
|
||||
}
|
||||
if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message);
|
||||
const seat = m.seat;
|
||||
if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" };
|
||||
const member = this.hosted.get(n)?.members.get(id);
|
||||
if (!member) return { type: "error", message: "not in that room" };
|
||||
if (member.seat !== seat) {
|
||||
member.seat = seat;
|
||||
this.participantsChanged(n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return { type: "error", message: "unknown message type" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
|
||||
const cookie = { v: "" };
|
||||
await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name });
|
||||
for (const a of ids) {
|
||||
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass });
|
||||
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass, picture: null });
|
||||
const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` });
|
||||
accounts.get(a.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// The T5 badge on people: who shows it, as the proxy asks.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
return Response.json({
|
||||
denormalized: u
|
||||
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
||||
: {},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
||||
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
stop = () => server.stop();
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
stop();
|
||||
await close();
|
||||
});
|
||||
|
||||
type Person = { cookie: string; token: string };
|
||||
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown, proxy = false) {
|
||||
const auth: Record<string, string> = p ? (proxy ? { Authorization: `Bearer ${p.token}` } : { Cookie: p.cookie }) : {};
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...auth },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
/** Signs a person up and links their IMVU accounts. */
|
||||
async function person(name: string, ...ids: number[]): Promise<Person> {
|
||||
const res = await fetch(base + "/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { Origin: webURL, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
|
||||
});
|
||||
const p = {
|
||||
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
|
||||
token: res.headers.get("set-auth-token") ?? "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const username = `${name}Av${i}`;
|
||||
accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null });
|
||||
const started = await call(p, "POST", "/api/v1/account/links", { username });
|
||||
accounts.get(id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const badges = async (p: Person | undefined, imvuIds: unknown) => call(p, "POST", "/api/v1/badges", { imvuIds }, true);
|
||||
|
||||
test("only linked accounts whose badge is on, of people who aren't banned, get the badge", async () => {
|
||||
const admin = await person("badgeadmin");
|
||||
const ann = await person("ann", 301, 302);
|
||||
const bob = await person("bob", 303);
|
||||
const asker = await person("asker");
|
||||
|
||||
// Off by default.
|
||||
const account = (await call(ann, "GET", "/api/v1/account")).json;
|
||||
expect(account.links.map((l: any) => l.showBadge)).toEqual([false, false]);
|
||||
expect((await badges(asker, [301, 302, 303, 999])).json).toEqual({ badges: [] });
|
||||
|
||||
// Turned on for one account; the other stays off.
|
||||
const patched = await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: true });
|
||||
expect(patched.json.link.showBadge).toBe(true);
|
||||
await call(bob, "PATCH", "/api/v1/account/links/303", { showBadge: true });
|
||||
const got = await badges(asker, [301, 302, 303, 999, 301]);
|
||||
expect(got.status).toBe(200);
|
||||
expect(got.json.badges.sort()).toEqual([301, 303]);
|
||||
expect(Object.keys(got.json)).toEqual(["badges"]);
|
||||
|
||||
// Turned off again.
|
||||
await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: false });
|
||||
expect((await badges(asker, [301, 303])).json).toEqual({ badges: [303] });
|
||||
|
||||
// A banned person's accounts don't.
|
||||
const bobId = (await call(bob, "GET", "/api/v1/me")).json.person.id;
|
||||
await db.update(schema.user).set({ banned: true }).where(eq(schema.user.id, bobId));
|
||||
expect((await badges(asker, [303])).json).toEqual({ badges: [] });
|
||||
|
||||
expect((await badges(asker, [])).json).toEqual({ badges: [] });
|
||||
expect((await badges(admin, [303])).status).toBe(200);
|
||||
});
|
||||
|
||||
test("lookups are validated and need signing in", async () => {
|
||||
const p = await person("carol");
|
||||
expect((await badges(undefined, [1])).status).toBe(401);
|
||||
expect((await badges(p, Array.from({ length: 201 }, (_, i) => i + 1))).status).toBe(400);
|
||||
expect((await badges(p, Array.from({ length: 200 }, (_, i) => i + 1))).status).toBe(200);
|
||||
for (const bad of [[0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], ["x"], null, "x", { id: 1 }])
|
||||
expect(JSON.stringify(bad) + " " + (await badges(p, bad)).status).toBe(JSON.stringify(bad) + " 400");
|
||||
expect((await call(p, "POST", "/api/v1/badges", {}, true)).status).toBe(400);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
|
||||
// proxies.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
return Response.json({
|
||||
denormalized: u
|
||||
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
||||
: {},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
||||
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
stop = () => server.stop();
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
stop();
|
||||
await close();
|
||||
});
|
||||
|
||||
type Person = { cookie: string; token: string; id: string };
|
||||
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
|
||||
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
|
||||
const res = await fetch(base + "/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { Origin: webURL, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
|
||||
});
|
||||
const p = {
|
||||
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
|
||||
token: res.headers.get("set-auth-token") ?? "",
|
||||
id: "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
|
||||
for (const [i, a] of imvu.entries()) {
|
||||
const username = `${name}Av${i || ""}`;
|
||||
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
|
||||
const started = await call(p, "POST", "/api/v1/account/links", { username });
|
||||
accounts.get(a.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A registered product with one revision (retracted: that revision is). */
|
||||
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
|
||||
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
|
||||
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
|
||||
}
|
||||
|
||||
/** A proxy's live connection, signed in to the client as imvuId. */
|
||||
async function proxy(p: Person, imvuId: number) {
|
||||
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
|
||||
const got: any[] = [];
|
||||
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const send = (m: object) => ws.send(JSON.stringify(m));
|
||||
send({ type: "session", imvu_id: imvuId });
|
||||
await Bun.sleep(30);
|
||||
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
|
||||
}
|
||||
|
||||
async function waitFor(ok: () => boolean) {
|
||||
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
||||
expect(ok()).toBe(true);
|
||||
}
|
||||
|
||||
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
|
||||
|
||||
test("only the room's owner decorates it; contents are checked", async () => {
|
||||
const owner = await person("dora", { id: 501 }, { id: 502 });
|
||||
const other = await person("dave", { id: 511 });
|
||||
const unlinked = await person("dina");
|
||||
await product("aaaaaaaaaa", owner.id, "general");
|
||||
const room = "502-3";
|
||||
const item = [7, "aaaaaaaaaa", "0 0 0"];
|
||||
|
||||
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
|
||||
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
||||
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
||||
|
||||
// Validation.
|
||||
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
|
||||
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
|
||||
expect((await put([item], bad)).status).toBe(400);
|
||||
expect((await put([item], "12345-2100000005")).json.error).toBe("hosted_room");
|
||||
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
|
||||
expect((await put([[7, 12345, ""]])).status).toBe(400);
|
||||
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
|
||||
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
|
||||
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
|
||||
expect((await put([item, item])).status).toBe(400);
|
||||
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
|
||||
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
|
||||
expect(unknown.status).toBe(400);
|
||||
expect(unknown.json.error).toBe("unknown_product");
|
||||
|
||||
// The owner's other account is fine too; up to 300 items.
|
||||
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.json.contents).toHaveLength(300);
|
||||
const set = await put([item]);
|
||||
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
|
||||
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
|
||||
// Not another person's room of the same number.
|
||||
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("viewers see the products they may, with a hash of what they see", async () => {
|
||||
const owner = await person("eli", { id: 601, ageVerified: true });
|
||||
const kid = await person("emma", { id: 611 });
|
||||
const grown = await person("evan", { id: 621, ageVerified: true });
|
||||
await product("cccccccccc", owner.id, "general");
|
||||
await product("dddddddddd", owner.id, "adult");
|
||||
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
|
||||
const room = "601-1";
|
||||
const g = [1, "cccccccccc", "a"];
|
||||
const a = [2, "dddddddddd", "b"];
|
||||
const r = [3, "eeeeeeeeee", "c"];
|
||||
|
||||
// Unknown rooms have nothing.
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/12345-2100000001")).status).toBe(400);
|
||||
|
||||
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
|
||||
|
||||
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
|
||||
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
||||
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
||||
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
|
||||
|
||||
// A product kept but hidden comes back once it has a usable revision again.
|
||||
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
|
||||
expect((await view(kid)).contents).toEqual([g, r]);
|
||||
|
||||
// Changes change the hash.
|
||||
const before = (await view(kid)).hash;
|
||||
const moved = [1, "cccccccccc", "moved"];
|
||||
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
|
||||
const after = await view(kid);
|
||||
expect(after.contents).toEqual([moved, r]);
|
||||
expect(after.hash).not.toBe(before);
|
||||
|
||||
// Several rooms at once, in the order asked.
|
||||
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
|
||||
expect(many.json.rooms).toEqual([
|
||||
{ instance: "601-9", contents: [], hash: hash([]) },
|
||||
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
|
||||
]);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
|
||||
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
|
||||
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
|
||||
|
||||
// An empty list removes them.
|
||||
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
|
||||
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("proxies in the room are told when its decorations change", async () => {
|
||||
const owner = await person("fay", { id: 701 });
|
||||
const guest = await person("finn", { id: 711 });
|
||||
const away = await person("fred", { id: 721 });
|
||||
await product("ffffffffff", owner.id, "general");
|
||||
const room = "701-2";
|
||||
|
||||
const host = await proxy(owner, 701); // the owner's client, in the room
|
||||
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
|
||||
const inRoom = await proxy(guest, 711);
|
||||
const elsewhere = await proxy(away, 721);
|
||||
host.send({ type: "join", chat: "1400000001", room });
|
||||
inRoom.send({ type: "join", chat: "1400000001", room });
|
||||
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
|
||||
elsewhere.send({ type: "join", chat: "1400000003" });
|
||||
await Bun.sleep(50);
|
||||
|
||||
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
|
||||
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
|
||||
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
|
||||
await Bun.sleep(50);
|
||||
expect(editor.decor()).toHaveLength(0);
|
||||
expect(elsewhere.decor()).toHaveLength(0);
|
||||
|
||||
// Not after leaving.
|
||||
inRoom.send({ type: "leave", chat: "1400000001" });
|
||||
await Bun.sleep(30);
|
||||
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
await waitFor(() => host.decor().length === 2);
|
||||
await Bun.sleep(50);
|
||||
expect(inRoom.decor()).toHaveLength(1);
|
||||
|
||||
// A refused change tells no one.
|
||||
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
await Bun.sleep(50);
|
||||
expect(host.decor()).toHaveLength(2);
|
||||
|
||||
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
return Response.json({
|
||||
denormalized: u
|
||||
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
||||
: {},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
||||
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
stop = () => server.stop();
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
stop();
|
||||
await close();
|
||||
});
|
||||
|
||||
type Person = { cookie: string; token: string };
|
||||
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
|
||||
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
|
||||
const res = await fetch(base + "/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { Origin: webURL, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
|
||||
});
|
||||
const p = {
|
||||
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
|
||||
token: res.headers.get("set-auth-token") ?? "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
for (const [i, a] of imvu.entries()) {
|
||||
const username = `${name}Av${i || ""}`;
|
||||
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
|
||||
const started = await call(p, "POST", "/api/v1/account/links", { username });
|
||||
accounts.get(a.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A proxy's live connection, signed in to the client as imvuId (0: none). */
|
||||
async function proxy(p: Person, imvuId: number) {
|
||||
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
|
||||
const got: any[] = [];
|
||||
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const send = (m: object) => ws.send(JSON.stringify(m));
|
||||
if (imvuId) {
|
||||
send({ type: "session", imvu_id: imvuId });
|
||||
await Bun.sleep(30);
|
||||
}
|
||||
const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1);
|
||||
/** Sends a message and waits for the answer of that type for that room. */
|
||||
const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => {
|
||||
const before = got.length;
|
||||
send(m);
|
||||
await waitFor(() => got.slice(before).some(answer));
|
||||
return got.slice(before).find(answer);
|
||||
};
|
||||
const join = (room: number) =>
|
||||
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
|
||||
return { ws, got, send, last, join, ask };
|
||||
}
|
||||
|
||||
async function waitFor(ok: () => boolean) {
|
||||
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
||||
expect(ok()).toBe(true);
|
||||
}
|
||||
|
||||
const newRoom = (imvuId: number, over: Record<string, unknown> = {}) => ({
|
||||
name: "Lounge",
|
||||
description: "A quiet place.",
|
||||
roomPid: 1234,
|
||||
rating: "general",
|
||||
maxUsers: 10,
|
||||
imvuId,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("rooms are made, listed, changed and closed by their owners", async () => {
|
||||
const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 });
|
||||
const ben = await person("benn", { id: 311 });
|
||||
|
||||
// Validation and permissions.
|
||||
expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid");
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400);
|
||||
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked");
|
||||
expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult");
|
||||
|
||||
// Made: owned by the chosen account, shown with its IMVU name.
|
||||
const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined }));
|
||||
expect(made.status).toBe(201);
|
||||
const n = made.json.room.n;
|
||||
expect(made.json.room).toMatchObject({
|
||||
instanceId: `302-${2_100_000_000 + n}`,
|
||||
chatId: 2_100_000_000 + n,
|
||||
name: "The Lounge",
|
||||
description: "A quiet place.",
|
||||
roomPid: 1234,
|
||||
rating: "general",
|
||||
maxUsers: 10,
|
||||
ownerImvuId: 302,
|
||||
ownerName: "adamAv1",
|
||||
occupancy: 0,
|
||||
});
|
||||
const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room;
|
||||
expect(adult.rating).toBe("adult");
|
||||
|
||||
// Listed newest first; adult rooms only for adults.
|
||||
const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name);
|
||||
expect(await names(ada)).toEqual(["Late", "The Lounge"]);
|
||||
expect(await names(ben)).toEqual(["The Lounge"]);
|
||||
expect(await names()).toEqual(["The Lounge"]);
|
||||
expect(await names(ben, "?mine=1")).toEqual([]);
|
||||
expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]);
|
||||
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401);
|
||||
|
||||
// One room, with its scene.
|
||||
const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
|
||||
expect(got.json.scene.contents).toEqual([]);
|
||||
expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404);
|
||||
expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404);
|
||||
expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400);
|
||||
|
||||
// Changes: owner only.
|
||||
expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403);
|
||||
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400);
|
||||
const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" });
|
||||
expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 });
|
||||
|
||||
// The scene: owner only, checked.
|
||||
const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] };
|
||||
expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403);
|
||||
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400);
|
||||
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400);
|
||||
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400);
|
||||
const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) };
|
||||
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400);
|
||||
const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.json.scene.contents).toEqual(scene.contents);
|
||||
expect(put.json.scene.hash).not.toBe(got.json.scene.hash);
|
||||
const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
|
||||
expect(after.json.scene).toEqual(put.json.scene);
|
||||
expect(after.json.room.roomPid).toBe(4321);
|
||||
|
||||
// Closing: owner only; then it's gone from lists and answers 410.
|
||||
expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403);
|
||||
expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
|
||||
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed");
|
||||
expect(await names(ada)).toEqual(["Late"]);
|
||||
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410);
|
||||
|
||||
// Unlinking the owner account hides its rooms.
|
||||
await call(ada, "DELETE", "/api/v1/account/links/301");
|
||||
expect(await names(ada)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a person may own up to 10 open rooms", async () => {
|
||||
const cy = await person("cyd", { id: 321 });
|
||||
const ns: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` }));
|
||||
expect(r.status).toBe(201);
|
||||
ns.push(r.json.room.n);
|
||||
}
|
||||
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap");
|
||||
await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`);
|
||||
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201);
|
||||
});
|
||||
|
||||
test("joining, seats and messages in a hosted room", async () => {
|
||||
const dee = await person("deen", { id: 401, ageVerified: true });
|
||||
const eli = await person("elia", { id: 402 });
|
||||
const fay = await person("faye", { id: 403 });
|
||||
const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room;
|
||||
const n: number = room.n;
|
||||
const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n;
|
||||
|
||||
const d = await proxy(dee, 401);
|
||||
const e = await proxy(eli, 402);
|
||||
const f = await proxy(fay, 403);
|
||||
|
||||
// Refusals: no linked account in use, no such room, adult room for a non-adult.
|
||||
const nobody = await proxy(eli, 0);
|
||||
expect((await nobody.join(n)).reason).toBe("not_linked");
|
||||
nobody.ws.close();
|
||||
const notMine = await proxy(eli, 403); // Fay's account, not Eli's
|
||||
expect((await notMine.join(n)).reason).toBe("not_linked");
|
||||
notMine.ws.close();
|
||||
expect((await e.join(999_999)).reason).toBe("not_found");
|
||||
expect((await e.join(adultRoom)).reason).toBe("adult");
|
||||
e.send({ type: "room.join", room: 0 });
|
||||
await waitFor(() => e.got.some((m) => m.message === "bad room"));
|
||||
|
||||
// Dee and Eli join; each sees the other.
|
||||
const dj = await d.join(n);
|
||||
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 });
|
||||
const ej = await e.join(n);
|
||||
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
|
||||
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
|
||||
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
|
||||
|
||||
// Full at two people; joining again (a reconnect) answers the same.
|
||||
expect((await f.join(n)).reason).toBe("full");
|
||||
expect((await e.join(n)).type).toBe("room.joined");
|
||||
|
||||
// Seats.
|
||||
e.send({ type: "room.seat", room: n, seat: 3 });
|
||||
await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3));
|
||||
e.send({ type: "room.seat", room: n, seat: 100 });
|
||||
await waitFor(() => e.got.some((m) => m.message === "bad seat"));
|
||||
|
||||
// Messages go to the others, numbered; forged senders are dropped.
|
||||
const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 });
|
||||
e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") });
|
||||
e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") });
|
||||
e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" });
|
||||
e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) });
|
||||
await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2);
|
||||
await Bun.sleep(50);
|
||||
const msgs = d.got.filter((m) => m.type === "room.message");
|
||||
expect(msgs).toEqual([
|
||||
{ type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 },
|
||||
{ type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 },
|
||||
]);
|
||||
expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender
|
||||
e.send({ type: "room.send", room: n, mount: "other", message: "x" });
|
||||
await waitFor(() => e.got.some((m) => m.message === "bad mount"));
|
||||
e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) });
|
||||
await waitFor(() => e.got.some((m) => m.message === "bad message"));
|
||||
f.send({ type: "room.send", room: n, mount: "control", message: "{}" });
|
||||
await waitFor(() => f.got.some((m) => m.message === "not in that room"));
|
||||
|
||||
// Rate: a burst of 20, then the rest is dropped.
|
||||
for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` });
|
||||
await Bun.sleep(150);
|
||||
const burst = d.got.filter((m) => m.type === "room.message").length - 2;
|
||||
expect(burst).toBeGreaterThanOrEqual(18);
|
||||
expect(burst).toBeLessThanOrEqual(23);
|
||||
expect((await d.join(n)).sequence).toBe(2 + burst);
|
||||
|
||||
// Leaving makes room; a closed connection leaves too.
|
||||
e.send({ type: "room.leave", room: n });
|
||||
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
|
||||
expect((await f.join(n)).type).toBe("room.joined");
|
||||
f.ws.close();
|
||||
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
|
||||
|
||||
// Closing tells everyone in it; joining it again is refused.
|
||||
expect((await e.join(n)).type).toBe("room.joined");
|
||||
expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
|
||||
await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined);
|
||||
expect((await e.join(n)).reason).toBe("closed");
|
||||
d.ws.close();
|
||||
e.ws.close();
|
||||
});
|
||||
|
||||
test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => {
|
||||
const gil = await person("gilt", { id: 501, ageVerified: true });
|
||||
const hal = await person("hale", { id: 502 });
|
||||
const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n;
|
||||
const g = await proxy(gil, 501);
|
||||
const h = await proxy(hal, 502);
|
||||
await g.join(n);
|
||||
await h.join(n);
|
||||
expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200);
|
||||
await waitFor(() => h.last("room.closed", n) !== undefined);
|
||||
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
|
||||
expect(g.last("room.closed", n)).toBeUndefined();
|
||||
|
||||
await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" });
|
||||
await h.join(n);
|
||||
await waitFor(() => g.last("room.participants", n)?.participants.length === 2);
|
||||
await call(hal, "DELETE", "/api/v1/account/links/502");
|
||||
await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2);
|
||||
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
|
||||
g.ws.close();
|
||||
h.ws.close();
|
||||
});
|
||||
|
||||
// ---- Phase 2 ----
|
||||
|
||||
test("moderators boot and ban; owners lift bans", async () => {
|
||||
const own = await person("ownr", { id: 601 });
|
||||
const mod = await person("modr", { id: 602 });
|
||||
const usr = await person("usrr", { id: 603 });
|
||||
const md2 = await person("mod2", { id: 604 });
|
||||
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only
|
||||
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
|
||||
|
||||
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
|
||||
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
|
||||
const many = Array.from({ length: 21 }, (_, i) => i + 1);
|
||||
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
|
||||
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
|
||||
"no_such_imvu_account",
|
||||
);
|
||||
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
|
||||
expect(set.status).toBe(200);
|
||||
expect(set.json.room.moderators).toEqual([602, 604, 605]);
|
||||
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
|
||||
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
|
||||
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
|
||||
|
||||
const o = await proxy(own, 601);
|
||||
const m = await proxy(mod, 602);
|
||||
const u = await proxy(usr, 603);
|
||||
const m2 = await proxy(md2, 604);
|
||||
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
|
||||
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
|
||||
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
|
||||
|
||||
// Who may boot whom.
|
||||
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
|
||||
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
|
||||
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
|
||||
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
|
||||
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
|
||||
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
|
||||
|
||||
// A boot: out, but may come back.
|
||||
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
|
||||
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
|
||||
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
|
||||
expect((await u.join(n)).type).toBe("room.joined");
|
||||
|
||||
// A ban: out, and refused.
|
||||
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
|
||||
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
|
||||
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
|
||||
|
||||
// Bans: listed for the owner and moderators; lifted by the owner only.
|
||||
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
|
||||
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
|
||||
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
|
||||
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
|
||||
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
|
||||
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
|
||||
expect((await u.join(n)).type).toBe("room.joined");
|
||||
|
||||
// A ban for a while; the owner may boot a moderator.
|
||||
await boot(o, 603, { ban: true, minutes: 60 });
|
||||
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
|
||||
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
|
||||
expect((await boot(o, 604)).ok).toBe(true);
|
||||
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
|
||||
|
||||
// Logged.
|
||||
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
|
||||
const actions = log.map((e) => e.action);
|
||||
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
|
||||
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
|
||||
expect(actions).toContain("hosted_room.unban");
|
||||
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
|
||||
for (const p of [o, m, u, m2]) p.ws.close();
|
||||
});
|
||||
|
||||
test("private rooms and invitations", async () => {
|
||||
const pam = await person("pamm", { id: 701 });
|
||||
const quin = await person("quin", { id: 702 });
|
||||
const rex = await person("rexx", { id: 703 }, { id: 705 });
|
||||
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
|
||||
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
|
||||
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
|
||||
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
|
||||
|
||||
// Listed only for the owner and moderators.
|
||||
const listed = async (p?: Person, q = "") =>
|
||||
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
|
||||
expect(await listed()).toBe(false);
|
||||
expect(await listed(rex)).toBe(false);
|
||||
expect(await listed(pam)).toBe(true);
|
||||
expect(await listed(quin)).toBe(true);
|
||||
expect(await listed(pam, "?mine=1")).toBe(true);
|
||||
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
|
||||
|
||||
const p = await proxy(pam, 701);
|
||||
const q = await proxy(quin, 702);
|
||||
const r = await proxy(rex, 703);
|
||||
expect((await r.join(n)).reason).toBe("private");
|
||||
expect((await q.join(n)).type).toBe("room.joined");
|
||||
const invite = (from: typeof p, room: number, imvu_id: number) =>
|
||||
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
|
||||
const joinWith = (x: typeof p, invite: string) =>
|
||||
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
|
||||
|
||||
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
|
||||
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
|
||||
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
|
||||
|
||||
// Invited: the invitee's sessions are told; it answers; the inviter hears.
|
||||
const sent = await invite(p, n, 703);
|
||||
expect(sent.ok).toBe(true);
|
||||
const id: string = sent.invite;
|
||||
await waitFor(() => r.last("room.invited", n) !== undefined);
|
||||
expect(r.last("room.invited", n)).toEqual({
|
||||
type: "room.invited",
|
||||
room: n,
|
||||
invite: id,
|
||||
from: 701,
|
||||
room_name: "Hideout",
|
||||
owner_name: "pammAv",
|
||||
});
|
||||
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
|
||||
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
|
||||
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
|
||||
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
|
||||
await waitFor(() => p.last("room.invite_answer") !== undefined);
|
||||
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
|
||||
r.send({ type: "room.invite_reply", invite: id, accept: false });
|
||||
await waitFor(() => r.got.some((x) => x.message === "already answered"));
|
||||
|
||||
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
|
||||
expect((await joinWith(r, id)).type).toBe("room.joined");
|
||||
r.ws.close();
|
||||
const r2 = await proxy(rex, 703);
|
||||
expect((await r2.join(n)).type).toBe("room.joined");
|
||||
r2.send({ type: "room.leave", room: n });
|
||||
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
|
||||
expect((await joinWith(r2, id)).reason).toBe("private");
|
||||
|
||||
// Declined.
|
||||
const second = (await invite(q, n, 703)).invite;
|
||||
await waitFor(() => r2.got.some((x) => x.invite === second));
|
||||
r2.send({ type: "room.invite_reply", invite: second, accept: false });
|
||||
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
|
||||
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
|
||||
expect((await joinWith(r2, second)).reason).toBe("private");
|
||||
|
||||
// Nobody online as that account; banned accounts aren't invited.
|
||||
const off = await invite(p, n, 705);
|
||||
expect(off.ok).toBe(true);
|
||||
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
|
||||
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
|
||||
type: "room.invite_answer",
|
||||
invite: off.invite,
|
||||
to: 705,
|
||||
accept: false,
|
||||
offline: true,
|
||||
});
|
||||
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
|
||||
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
|
||||
|
||||
// Public rooms: anyone in it invites.
|
||||
expect((await r2.join(pub)).type).toBe("room.joined");
|
||||
expect((await invite(r2, pub, 701)).ok).toBe(true);
|
||||
await waitFor(() => p.last("room.invited", pub) !== undefined);
|
||||
for (const x of [p, q, r2]) x.ws.close();
|
||||
});
|
||||
|
||||
const png = (bytes = 64) => {
|
||||
const b = new Uint8Array(bytes);
|
||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
return b;
|
||||
};
|
||||
|
||||
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
|
||||
const form = new FormData();
|
||||
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
|
||||
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
|
||||
method: "PUT",
|
||||
headers: { Origin: webURL, Cookie: p.cookie },
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
test("room images", async () => {
|
||||
const ivy = await person("ivyy", { id: 801 });
|
||||
const jon = await person("jonn", { id: 802 });
|
||||
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
|
||||
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
|
||||
|
||||
expect((await upload(jon, n, png())).status).toBe(403);
|
||||
const gif = new TextEncoder().encode("GIF89a................................");
|
||||
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
|
||||
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
expect((await upload(ivy, n, svg)).status).toBe(400);
|
||||
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
|
||||
const up = await upload(ivy, n, png());
|
||||
expect(up.status).toBe(200);
|
||||
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
|
||||
|
||||
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
|
||||
expect(got.status).toBe(200);
|
||||
expect(got.headers.get("content-type")).toBe("image/png");
|
||||
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
|
||||
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
|
||||
const etag = got.headers.get("etag")!;
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
|
||||
|
||||
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
|
||||
expect((await upload(ivy, n, jpeg)).status).toBe(200);
|
||||
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
|
||||
expect(again.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(again.headers.get("etag")).not.toBe(etag);
|
||||
|
||||
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
|
||||
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
|
||||
});
|
||||
|
||||
test("favorites, ratings and recent rooms", async () => {
|
||||
const kim = await person("kimm", { id: 901 });
|
||||
const lou = await person("louu", { id: 902 });
|
||||
const mia = await person("miaa", { id: 903 });
|
||||
const ns: number[] = [];
|
||||
for (const name of ["One", "Two", "Three"])
|
||||
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
|
||||
const [a, b, c] = ns as [number, number, number];
|
||||
|
||||
// Favorites.
|
||||
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
|
||||
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
|
||||
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
|
||||
expect(await favs(lou)).toEqual([c, a]);
|
||||
expect(await favs(mia)).toEqual([]);
|
||||
const flag = async (p: Person | undefined, n: number) =>
|
||||
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
|
||||
expect(await flag(lou, a)).toBe(true);
|
||||
expect(await flag(mia, a)).toBe(false);
|
||||
expect(await flag(undefined, a)).toBe(false);
|
||||
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
|
||||
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
|
||||
expect(await favs(lou)).toEqual([c]);
|
||||
|
||||
// Ratings.
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
|
||||
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
|
||||
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
|
||||
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
|
||||
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
|
||||
expect(one.json.myRating).toBe(4);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
|
||||
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
|
||||
|
||||
// Recent rooms: joined, latest first, each once.
|
||||
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
|
||||
const l = await proxy(lou, 902);
|
||||
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
|
||||
for (const n of [a, b, c]) {
|
||||
await l.join(n);
|
||||
await Bun.sleep(20);
|
||||
}
|
||||
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
|
||||
expect(await recent()).toEqual([c, b, a]);
|
||||
l.send({ type: "room.leave", room: a });
|
||||
await Bun.sleep(30);
|
||||
await l.join(a);
|
||||
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
|
||||
expect(await recent()).toEqual([a, c, b]);
|
||||
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
|
||||
expect(visit.map((v) => v.imvuId)).toEqual([902]);
|
||||
l.ws.close();
|
||||
});
|
||||
|
||||
test("watching rooms' occupancy", async () => {
|
||||
const ned = await person("nedd", { id: 1001 });
|
||||
const ola = await person("olaa", { id: 1002 });
|
||||
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
|
||||
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
|
||||
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
|
||||
|
||||
const w = await proxy(ola, 1002);
|
||||
const n = await proxy(ned, 1001);
|
||||
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
|
||||
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
|
||||
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
|
||||
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
|
||||
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
|
||||
// The private room's owner may watch it.
|
||||
n.send({ type: "room.watch", rooms: [hidden] });
|
||||
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
|
||||
|
||||
await n.join(a);
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
|
||||
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
|
||||
n.send({ type: "room.seat", room: a, seat: 2 });
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
|
||||
n.send({ type: "room.leave", room: a });
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
|
||||
await n.join(hidden);
|
||||
await Bun.sleep(50);
|
||||
expect(w.last("room.occupancy", hidden)).toBeUndefined();
|
||||
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
|
||||
|
||||
// Replaced; closing tells watchers it's empty.
|
||||
w.send({ type: "room.watch", rooms: [b] });
|
||||
await Bun.sleep(50);
|
||||
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
|
||||
await n.join(a);
|
||||
await n.join(b);
|
||||
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
|
||||
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
|
||||
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
|
||||
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
|
||||
w.ws.close();
|
||||
n.ws.close();
|
||||
});
|
||||
@@ -130,14 +130,14 @@ describe("linking IMVU accounts", () => {
|
||||
test("by tagline; the most recent proof wins; link caps", async () => {
|
||||
const carol = new Browser();
|
||||
await carol.signup("carol");
|
||||
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false });
|
||||
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null });
|
||||
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
|
||||
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
|
||||
const missing = await carol.call("POST", "/api/v1/account/links/42/check");
|
||||
expect(missing.status).toBe(409);
|
||||
expect(missing.json.error).toBe("code_missing");
|
||||
|
||||
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false });
|
||||
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null });
|
||||
expect(linked.status).toBe(200);
|
||||
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
|
||||
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
|
||||
@@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => {
|
||||
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
|
||||
const dave = new Browser();
|
||||
await dave.signup("dave");
|
||||
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false });
|
||||
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null });
|
||||
expect(moved.json.moved).toBe(true);
|
||||
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
|
||||
expect(after.userId).not.toBe(before.userId);
|
||||
@@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => {
|
||||
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
|
||||
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
|
||||
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
|
||||
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false });
|
||||
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null });
|
||||
expect(over.status).toBe(403);
|
||||
expect(over.json.error).toBe("link_cap");
|
||||
});
|
||||
@@ -170,7 +170,7 @@ describe("T5DE's proxy", () => {
|
||||
test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
|
||||
const erin = new Browser();
|
||||
await erin.signup("erin");
|
||||
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false });
|
||||
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null });
|
||||
|
||||
// The proxy asks for a code; until it's approved, the token request waits.
|
||||
const codeRes = await fetch(`${base}/api/auth/device/code`, {
|
||||
@@ -216,7 +216,7 @@ describe("T5DE's proxy", () => {
|
||||
// An account linked while the client runs counts at once.
|
||||
ws.send(JSON.stringify({ type: "session", imvu_id: 78 }));
|
||||
await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2);
|
||||
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false });
|
||||
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null });
|
||||
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78));
|
||||
await erin.call("DELETE", "/api/v1/account/links/78");
|
||||
await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78));
|
||||
|
||||
@@ -4,13 +4,23 @@ import { createLocalJWKSet, jwtVerify } from "jose";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
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 apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
|
||||
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const pm = url.pathname.match(/^\/product\/product-(\d+)$/);
|
||||
if (pm) {
|
||||
const pid = Number(pm[1]);
|
||||
const data = imvuProducts.get(pid);
|
||||
if (data === "down") throw new Error("connection refused");
|
||||
if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 });
|
||||
return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } });
|
||||
}
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
@@ -25,11 +35,12 @@ let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
let hub: ReturnType<typeof createApp>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
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" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
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("; ") };
|
||||
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` });
|
||||
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
|
||||
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;
|
||||
}
|
||||
|
||||
// Set by the first test for the next: the admin and an approved catalog.
|
||||
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
|
||||
|
||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
||||
|
||||
test("catalogs register uploads; products have revisions, info and libraries", async () => {
|
||||
@@ -92,6 +106,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
||||
expect(reg.status).toBe(201);
|
||||
const catalogID = reg.json.catalog.id as string;
|
||||
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
|
||||
shared = { admin, catalogID, auth };
|
||||
expect(reg.json.catalog.url).toBe("http://localhost:4102");
|
||||
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
|
||||
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
|
||||
@@ -171,3 +186,130 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
||||
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
|
||||
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
|
||||
});
|
||||
|
||||
test("IMVU product info: parsing and image addresses", () => {
|
||||
expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png");
|
||||
expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png");
|
||||
for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://[email protected]/a.png", "javascript:alert(1)", 5, null])
|
||||
expect(imvuImage(bad)).toBeNull();
|
||||
const answer = {
|
||||
denormalized: {
|
||||
"https://api.imvu.com/product/product-9": {
|
||||
data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseProduct(answer, 9)).toEqual({
|
||||
image: "https://x.imvu.com/i.png",
|
||||
categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }],
|
||||
gender: "female",
|
||||
});
|
||||
expect(parseProduct(answer, 10)).toBeNull();
|
||||
});
|
||||
|
||||
test("derived products carry their IMVU parent's image and categories", async () => {
|
||||
const { catalogID, auth } = shared;
|
||||
const dresser = await person("dresser", { id: 601, ageVerified: false });
|
||||
const coat = {
|
||||
product_id: 7001,
|
||||
product_name: "Coat",
|
||||
rating: "GA",
|
||||
product_image: "https://userimages-akm.imvu.com/productdata/coat.png",
|
||||
gender: "female",
|
||||
category_path: [
|
||||
{ id: 106, name: "Clothing" },
|
||||
{ id: 40, name: "Clothing for Female" },
|
||||
{ id: 128, name: "Tops" },
|
||||
{ id: 716, name: "Coats & Jackets" },
|
||||
],
|
||||
};
|
||||
imvuProducts.set(7001, coat);
|
||||
imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] });
|
||||
imvuProducts.set(7003, "down");
|
||||
const upload = async (name: string, parent: number) => {
|
||||
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
|
||||
expect(r.status).toBe(201);
|
||||
return r.json.product as string;
|
||||
};
|
||||
const coatID = await upload("Dresser Coat", 7001);
|
||||
const hatID = await upload("Dresser Hat", 7002);
|
||||
const downID = await upload("Dresser Down", 7003);
|
||||
const plainID = await upload("Dresser Plain", 0);
|
||||
const missingID = await upload("Dresser Missing", 7999);
|
||||
|
||||
const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product;
|
||||
// Looked up in the background after registering.
|
||||
for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20);
|
||||
expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
|
||||
for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20);
|
||||
expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] });
|
||||
expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] });
|
||||
expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] });
|
||||
|
||||
// Lists carry the same fields; category filters by any ID in the path.
|
||||
const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products;
|
||||
const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort();
|
||||
expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
|
||||
expect(await names("&category=128")).toEqual(["Dresser Coat"]);
|
||||
expect(await names("&category=106")).toEqual(["Dresser Coat"]);
|
||||
expect(await names("&category=75")).toEqual(["Dresser Hat"]);
|
||||
expect(await names("&category=1")).toEqual([]);
|
||||
expect(await names("&limit=2")).toHaveLength(2);
|
||||
expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400);
|
||||
expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400);
|
||||
|
||||
// IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty.
|
||||
imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] });
|
||||
await hub.products.fillParentInfo(50);
|
||||
expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] });
|
||||
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
|
||||
expect(await hub.products.fillParentInfo(50)).toBe(0);
|
||||
|
||||
// The library shows them too.
|
||||
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
|
||||
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
|
||||
});
|
||||
|
||||
test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => {
|
||||
const user = (thumbnail_url: unknown) =>
|
||||
parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } });
|
||||
expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png");
|
||||
expect(user("https://evil.example/p.png").picture).toBeNull();
|
||||
expect(user(undefined).picture).toBeNull();
|
||||
});
|
||||
|
||||
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
|
||||
const { admin, catalogID, auth } = shared;
|
||||
const maker = await person("deleter", { id: 511, ageVerified: true });
|
||||
const owner = await person("keeper", { id: 512, ageVerified: true });
|
||||
const late = await person("latecomer", { id: 513, ageVerified: true });
|
||||
const make = async (name: string) => {
|
||||
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
|
||||
return r.json.product as string;
|
||||
};
|
||||
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
|
||||
const id = await make("Gone Hat");
|
||||
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
||||
|
||||
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
|
||||
// the files stay for those who have it.
|
||||
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
|
||||
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
|
||||
expect(await listed()).toEqual([]);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
|
||||
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
|
||||
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
|
||||
|
||||
// Moderation removes one: no page, no files, out of libraries.
|
||||
const bad = await make("Gone Shoes");
|
||||
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
|
||||
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
|
||||
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||
});
|
||||
@@ -88,7 +88,7 @@ async function person(name: string, imvu: { id: number; ageVerified?: boolean })
|
||||
token: res.headers.get("set-auth-token") ?? "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false });
|
||||
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false, picture: null });
|
||||
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
|
||||
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
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: "Create" };
|
||||
|
||||
export default async function Create() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/create");
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Create a product">Upload it to a catalog. You can edit its info after.</PageHeader>
|
||||
<UploadCard title="Upload" />
|
||||
</>
|
||||
);
|
||||
// Where creating a product used to be.
|
||||
export default function Create() {
|
||||
redirect("/products/new");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<style>
|
||||
rect { fill: #171717; } text { fill: #fafafa; }
|
||||
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
|
||||
</style>
|
||||
<rect width="32" height="32" rx="7"/>
|
||||
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Header } from "@/components/header";
|
||||
import { StagingBanner } from "@/components/staging-banner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -16,6 +17,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
|
||||
<body className="flex min-h-dvh flex-col">
|
||||
<StagingBanner />
|
||||
<Header />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
|
||||
<footer className="border-t">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, RatingTag } from "@/components/products";
|
||||
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 { getMe, type Revision } from "@/lib/api";
|
||||
import { libraryAction, retract } from "../actions";
|
||||
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> {
|
||||
@@ -27,11 +28,31 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
<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">
|
||||
{me ? (
|
||||
{product.deleted && !inLibrary ? null : me ? (
|
||||
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{inLibrary ? (
|
||||
@@ -53,10 +74,21 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Sign in to get it
|
||||
</Link>
|
||||
)}
|
||||
{mine && (
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
Edit
|
||||
</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>
|
||||
|
||||
@@ -69,6 +101,26 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
</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>
|
||||
|
||||
@@ -32,6 +32,30 @@ export async function libraryAction(_: ActionState, form: FormData): Promise<Act
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 () => {
|
||||
|
||||
@@ -2,13 +2,13 @@ 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). */
|
||||
/** 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) notFound();
|
||||
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { UploadCard } from "@/components/upload-card";
|
||||
import { getMe } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "New product" };
|
||||
|
||||
export default async function NewProduct() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/products/new");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="New product">
|
||||
Upload it to a catalog. You can edit its info after.{" "}
|
||||
<Link href="/products" className="underline-offset-4 hover:underline">
|
||||
Back to products
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<UploadCard title="Upload" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Empty, ProductCard } from "@/components/products";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
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 { products } = await api.products.get({ query: q ? { q } : {} }).then(unwrap);
|
||||
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Products">Community products for T5DE.</PageHeader>
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { closeRoom, removeImage, saveImage, saveRoom } from "../../actions";
|
||||
import { RoomFields } from "../../room-fields";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit room" };
|
||||
|
||||
export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) {
|
||||
const { n } = await params;
|
||||
if (!/^[0-9]{1,9}$/.test(n)) notFound();
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=/rooms/${n}/edit`);
|
||||
const [{ rooms }, { links }] = await Promise.all([
|
||||
api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap),
|
||||
api.account.get().then(unwrap),
|
||||
]);
|
||||
const room = rooms.find((r) => String(r.n) === n);
|
||||
if (!room) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={`Edit ${room.name}`}>
|
||||
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||
Back to rooms
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Owned by {room.ownerName} · {room.occupancy} here now
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveRoom} fields={{ n: room.n }}>
|
||||
<FieldGroup>
|
||||
<RoomFields room={room} canAdult={links.some((l) => l.adult)} moderators />
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Image</CardTitle>
|
||||
<CardDescription>Shown in room lists.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{room.image && (
|
||||
// The room's image, from the hub.
|
||||
<img src={room.image} alt="" className="aspect-[332/281] w-full max-w-xs rounded-md bg-muted object-cover" />
|
||||
)}
|
||||
<ActionForm action={saveImage} fields={{ n: room.n }}>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="image">{room.image ? "Replace" : "Upload"}</FieldLabel>
|
||||
<Input id="image" name="image" type="file" accept="image/png,image/jpeg" required className="max-w-md" />
|
||||
<FieldDescription>PNG or JPEG, up to 512 KB.</FieldDescription>
|
||||
</Field>
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Uploading…">Save image</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
{room.image && (
|
||||
<ActionForm action={removeImage} fields={{ n: room.n }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Removing…">
|
||||
Remove image
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Close room</CardTitle>
|
||||
<CardDescription>Everyone in it is sent out. It can't be opened again.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={closeRoom} fields={{ n: room.n }}>
|
||||
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
|
||||
Close room
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
|
||||
const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const));
|
||||
const moderators = (form: FormData) =>
|
||||
field(form, "moderators")
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Makes a hosted room, then goes back to the list. */
|
||||
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const state = await attempt(async () => {
|
||||
unwrap(
|
||||
await api["hosted-rooms"].post({
|
||||
name: field(form, "name"),
|
||||
description: field(form, "description"),
|
||||
roomPid: Number(field(form, "roomPid")),
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
imvuId: Number(field(form, "imvuId")),
|
||||
privacy: privacy(form),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
|
||||
/** The owner's changes to a room. */
|
||||
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(
|
||||
await api["hosted-rooms"]({ n }).patch({
|
||||
name: field(form, "name"),
|
||||
description: field(form, "description"),
|
||||
roomPid: Number(field(form, "roomPid")),
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
privacy: privacy(form),
|
||||
moderators: moderators(form),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
|
||||
/** The room's image: a PNG or JPEG, up to 512 KB. */
|
||||
export async function saveImage(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const image = form.get("image");
|
||||
if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." };
|
||||
if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." };
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).image.put({ image }));
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath(`/rooms/${n}/edit`);
|
||||
return "Image saved.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeImage(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).image.delete());
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath(`/rooms/${n}/edit`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stars a room, or (favorite=0) unstars it. */
|
||||
export async function setFavorite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
const room = api["hosted-rooms"]({ n }).favorite;
|
||||
unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete()));
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
}
|
||||
|
||||
export async function rateRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) }));
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes a room: everyone in it is sent out. */
|
||||
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).delete());
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { createRoom } from "../actions";
|
||||
import { RoomFields } from "../room-fields";
|
||||
|
||||
export const metadata: Metadata = { title: "New room" };
|
||||
|
||||
export default async function NewRoom() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/rooms/new");
|
||||
const { links } = await api.account.get().then(unwrap);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="New room">
|
||||
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||
Back to rooms
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{links.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||
Link an IMVU account
|
||||
</Link>{" "}
|
||||
first: it's shown as the room's owner.
|
||||
</p>
|
||||
) : (
|
||||
<ActionForm action={createRoom}>
|
||||
<FieldGroup>
|
||||
<RoomFields canAdult={links.some((l) => l.adult)} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
|
||||
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
|
||||
{links.map((l) => (
|
||||
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
|
||||
{l.username}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
|
||||
</Field>
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+117
-43
@@ -1,55 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api, 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 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 (
|
||||
<>
|
||||
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
|
||||
{rooms.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{rooms.map((r) => (
|
||||
<li key={r.instanceId}>
|
||||
<Card className="h-full overflow-hidden py-0">
|
||||
{r.imageURL && (
|
||||
// IMVU's own image of the room, from IMVU.
|
||||
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||
)}
|
||||
<CardContent className="space-y-3 pb-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
||||
{r.name}
|
||||
</a>
|
||||
{r.adult && <Tag tone="adult">AP</Tag>}
|
||||
{r.t5de.length > 0 && (
|
||||
<Tag tone="online" dot>
|
||||
{r.t5de.length} on T5DE
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.owner.username} · {r.occupancy}/{r.capacity}
|
||||
</p>
|
||||
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||
{r.t5de.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
|
||||
{me && (
|
||||
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
|
||||
New room
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
|
||||
{hosted.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{hosted.map((r) => (
|
||||
<li key={r.n}>
|
||||
<HostedCard room={r} mine={own.has(r.n)} signedIn={!!me} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
|
||||
{rooms.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{rooms.map((r) => (
|
||||
<li key={r.instanceId}>
|
||||
<Card className="h-full overflow-hidden py-0">
|
||||
{r.imageURL && (
|
||||
// IMVU's own image of the room, from IMVU.
|
||||
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CardContent className="space-y-3 pb-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
||||
{r.name}
|
||||
</a>
|
||||
{r.adult && <Tag tone="adult">AP</Tag>}
|
||||
{r.t5de.length > 0 && (
|
||||
<Tag tone="online" dot>
|
||||
{r.t5de.length} on T5DE
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.owner.username} · {r.occupancy}/{r.capacity}
|
||||
</p>
|
||||
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||
{r.t5de.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
|
||||
return (
|
||||
<Card className="h-full overflow-hidden py-0">
|
||||
{r.image && (
|
||||
// The room's image, from the hub.
|
||||
<img src={r.image} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||
)}
|
||||
</>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
|
||||
{r.privacy === "private" && <Tag>Private</Tag>}
|
||||
{r.occupancy > 0 && (
|
||||
<Tag tone="online" dot>
|
||||
{r.occupancy} here
|
||||
</Tag>
|
||||
)}
|
||||
{mine && (
|
||||
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.ownerName} · {r.occupancy}/{r.maxUsers}
|
||||
{r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}
|
||||
</p>
|
||||
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||
{signedIn && <RoomActions n={r.n} favorite={r.favorite} canRate={!mine} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
// A hosted room card's favorite star and rating buttons.
|
||||
import { Star } from "lucide-react";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rateRoom, setFavorite } from "./actions";
|
||||
|
||||
export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActionForm action={setFavorite} fields={{ n, favorite: favorite ? "0" : "1" }} className="space-y-0">
|
||||
<SubmitButton variant="ghost" size="icon" aria-label={favorite ? "Unstar" : "Star"} title={favorite ? "Unstar" : "Star"}>
|
||||
<Star className={cn("size-4", favorite && "fill-current text-amber-500")} />
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
{canRate && (
|
||||
<ActionForm action={rateRoom} fields={{ n }} className="flex items-center gap-0.5 space-y-0">
|
||||
<span className="mr-1 text-xs text-muted-foreground">Rate</span>
|
||||
{[1, 2, 3, 4, 5].map((k) => (
|
||||
<SubmitButton key={k} name="score" value={String(k)} variant="ghost" size="xs" aria-label={`${k} of 5`}>
|
||||
{k}
|
||||
</SubmitButton>
|
||||
))}
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// The fields of a hosted room, for making one and for editing it.
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { HostedRoom } from "@/lib/api";
|
||||
|
||||
/** The moderators as the edit field shows them: names where known, else IDs. */
|
||||
const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", ");
|
||||
|
||||
export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="description">Description</FieldLabel>
|
||||
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
|
||||
<Input
|
||||
id="roomPid"
|
||||
name="roomPid"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
required
|
||||
defaultValue={room?.roomPid}
|
||||
placeholder="Product ID"
|
||||
/>
|
||||
<FieldDescription>An IMVU room product's ID, from its shop page.</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
|
||||
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
|
||||
Adult
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
|
||||
<Input
|
||||
id="maxUsers"
|
||||
name="maxUsers"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={2}
|
||||
max={30}
|
||||
required
|
||||
defaultValue={room?.maxUsers ?? 10}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="privacy">Privacy</FieldLabel>
|
||||
<NativeSelect id="privacy" name="privacy" defaultValue={room?.privacy ?? "public"} className="w-64">
|
||||
<NativeSelectOption value="public">Public</NativeSelectOption>
|
||||
<NativeSelectOption value="private">Private</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<FieldDescription>Private: unlisted; only you, moderators and people invited get in.</FieldDescription>
|
||||
</Field>
|
||||
{moderators && room && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="moderators">Moderators</FieldLabel>
|
||||
<Input id="moderators" name="moderators" defaultValue={moderatorList(room)} placeholder="IMVU names or IDs" />
|
||||
<FieldDescription>Up to 20, comma-separated. They can boot and ban.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -32,21 +33,67 @@ export function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutButton() {
|
||||
/** The signed-in person's menu: their picture and name, opening their pages and signing out. */
|
||||
export function ProfileMenu({ username, picture, console }: { username: string; picture: string | null; console: boolean }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const item =
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-muted";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
className="flex items-center gap-2 rounded-full py-1 pr-2.5 pl-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring data-[popup-open]:bg-muted"
|
||||
aria-label="Your account"
|
||||
>
|
||||
<Avatar username={username} picture={picture} />
|
||||
<span className="hidden max-w-40 truncate sm:inline">{username}</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner align="end" sideOffset={6} className="z-50">
|
||||
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
|
||||
<Menu.LinkItem className={item} render={<Link href="/account" />}>
|
||||
Account
|
||||
</Menu.LinkItem>
|
||||
<Menu.LinkItem className={item} render={<Link href="/library" />}>
|
||||
Library
|
||||
</Menu.LinkItem>
|
||||
{console && (
|
||||
<Menu.LinkItem className={item} render={<Link href="/console" />}>
|
||||
Console
|
||||
</Menu.LinkItem>
|
||||
)}
|
||||
<Menu.Separator className="-mx-1 my-1 h-px bg-border" />
|
||||
<Menu.Item
|
||||
className={item}
|
||||
disabled={signingOut}
|
||||
onClick={async () => {
|
||||
setSigningOut(true);
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** A person's picture (their first linked IMVU account's avatar), or their initial. */
|
||||
function Avatar({ username, picture }: { username: string; picture: string | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (picture && !failed) {
|
||||
return (
|
||||
<img src={picture} alt="" onError={() => setFailed(true)} className="size-7 rounded-full bg-muted object-cover object-top" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="grid size-7 place-items-center rounded-full bg-muted text-xs font-semibold uppercase" aria-hidden>
|
||||
{username.slice(0, 1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getMe, isModerator } from "@/lib/api";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NavLink, SignOutButton } from "./header-client";
|
||||
import { NavLink, ProfileMenu } from "./header-client";
|
||||
import logo from "./t5de.png";
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<span
|
||||
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
|
||||
aria-hidden
|
||||
>
|
||||
T5
|
||||
</span>
|
||||
);
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return <Image src={logo} alt="T5DE" priority className={className ?? "size-9"} />;
|
||||
}
|
||||
|
||||
export async function Header() {
|
||||
@@ -19,26 +14,21 @@ export async function Header() {
|
||||
return (
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<Link href="/" aria-label="T5DE Hub home" className="shrink-0">
|
||||
<Logo />
|
||||
T5DE Hub
|
||||
</Link>
|
||||
<nav className="order-last flex w-full flex-wrap items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||
<NavLink href="/online">Online</NavLink>
|
||||
<NavLink href="/rooms">Rooms</NavLink>
|
||||
<NavLink href="/products">Products</NavLink>
|
||||
<NavLink href="/catalogs">Catalogs</NavLink>
|
||||
{me && <NavLink href="/library">Library</NavLink>}
|
||||
{me && <NavLink href="/create">Create</NavLink>}
|
||||
{me && <NavLink href="/account">Account</NavLink>}
|
||||
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
<a href="https://t5de.dev/download" className={buttonVariants({ variant: "ghost" })}>
|
||||
Download
|
||||
</a>
|
||||
{me ? (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
|
||||
<SignOutButton />
|
||||
</>
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||
|
||||
@@ -21,15 +21,31 @@ export function formatSize(bytes: number): string {
|
||||
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">;
|
||||
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">
|
||||
@@ -38,7 +54,7 @@ export function ProductCard({
|
||||
<RatingTag rating={product.rating} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[product.creator && `by ${product.creator}`, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
||||
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{children}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// A slim bar on staging, so testers don't mistake it for T5DE's hub: shown when HUB_ENVIRONMENT is "staging", or
|
||||
// (unset) when the site is served from a staging address, such as hub.staging.t5de.dev. Read per request, since
|
||||
// staging and production run the same image.
|
||||
export async function StagingBanner() {
|
||||
const env = process.env.HUB_ENVIRONMENT;
|
||||
const host = (await headers()).get("host") ?? "";
|
||||
const staging = env ? env === "staging" : /(^|\.)staging\./.test(host);
|
||||
if (!staging) return null;
|
||||
return (
|
||||
<div className="bg-amber-400 py-1 text-center text-xs font-medium text-amber-950">
|
||||
Staging ·{" "}
|
||||
<a href="https://hub.t5de.dev" className="underline underline-offset-2">
|
||||
hub.t5de.dev
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+2
-1
@@ -45,6 +45,7 @@ export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||
export type Revision = ProductPage["revisions"][number];
|
||||
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
||||
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
||||
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
|
||||
|
||||
/** An error the API answered: message is written to be shown as is. */
|
||||
export class ApiError extends Error {
|
||||
@@ -80,4 +81,4 @@ export const getMe = cache(async (): Promise<Person | undefined> => {
|
||||
return data && "person" in data ? data.person : undefined;
|
||||
});
|
||||
|
||||
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||
export const isModerator = (p: Pick<Person, "role"> | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# git-cliff (https://git-cliff.org), for the release workflow (.gitea/workflows/release.yml): the next version and
|
||||
# CHANGELOG.md, from the conventional commits on main (one per pull request, titled by it) since the last vX.Y.Z tag.
|
||||
# feat → minor, fix/perf/revert → patch, a breaking change (`!` or BREAKING CHANGE:) → minor while 0.x, major after.
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
|
||||
"""
|
||||
body = """
|
||||
{% if commits | length > 0 %}
|
||||
{% if version %}## {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }}){% else %}## Unreleased{% endif %}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | upper_first }}{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
"""
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
protect_breaking_commits = true
|
||||
commit_parsers = [
|
||||
{ message = "^chore\\(release\\)", skip = true },
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^revert", group = "<!-- 3 -->Reverts" },
|
||||
{ message = "^refactor", group = "<!-- 4 -->Refactoring" },
|
||||
{ message = "^docs", group = "<!-- 5 -->Documentation" },
|
||||
{ message = "^(build|ci|chore|style|test)", skip = true },
|
||||
]
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+"
|
||||
sort_commits = "oldest"
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "v0.1.0"
|
||||
# Matched against the commit's type: these alone don't make a release.
|
||||
no_increment_regex = "^(chore|ci|docs|test|style|build|refactor)$"
|
||||
+45
-4
@@ -11,7 +11,7 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session tok
|
||||
|
||||
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
|
||||
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`.
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`.
|
||||
|
||||
## Accounts (Better Auth)
|
||||
|
||||
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
|
||||
| `POST /api/auth/sign-in/username` | `{username, password}` |
|
||||
| `POST /api/auth/sign-out` | |
|
||||
| `GET /api/v1/me` | `{person}` |
|
||||
| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
|
||||
|
||||
## Proxies
|
||||
|
||||
@@ -29,7 +29,8 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
|
||||
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
|
||||
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
|
||||
| `GET /api/v1/realtime` | WebSocket, bearer token: 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 |
|
||||
|
||||
## Website routes (signed in unless noted)
|
||||
@@ -44,11 +45,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/:imvu/check` | `{link, moved}` |
|
||||
| `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) |
|
||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
||||
|
||||
## Hosted rooms
|
||||
|
||||
Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session;
|
||||
the `GET`s also signed out.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId, privacy?}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
|
||||
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. `?mine=1`: the person's own; `?favorites=1`: their favorites; `?recent=1`: the last 20 they joined. |
|
||||
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}, myRating}`; 404, or 410 `closed` |
|
||||
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?}` → `{room}`. Made adult: non-adults in it are sent out. |
|
||||
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
|
||||
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
|
||||
| `GET .../:n/participants`, `GET .../:n/bans`, `DELETE .../:n/bans/:imvu`, `GET/PUT/DELETE .../:n/image`, `PUT/DELETE .../:n/favorite`, `PUT/DELETE .../:n/rating` | Phase 2: docs/hosted-rooms.md, "Phase 2" |
|
||||
|
||||
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||
privacy, moderators, moderatorNames, image, favorite, score, ratings,
|
||||
createdAt}`; `instanceId` is `<owner IMVU ID>-<2100000000 + n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance,
|
||||
`<owner's customer ID>-<n>` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (room number 2100000000 and up) are refused
|
||||
(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents`
|
||||
is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the
|
||||
sha256 (hex) of `contents` as JSON.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. |
|
||||
| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked |
|
||||
| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. |
|
||||
|
||||
## Products, catalogs and libraries
|
||||
|
||||
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
||||
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the
|
||||
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
|
||||
category ID) and `limit` (1-200, default 60).
|
||||
|
||||
## Age verification
|
||||
|
||||
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
|
||||
|
||||
+7
-4
@@ -287,8 +287,8 @@ is per person, shared across their accounts.
|
||||
|
||||
## Rooms (**Decided**: both)
|
||||
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements
|
||||
travel on the room channel like outfits.
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps
|
||||
them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations").
|
||||
- **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions,
|
||||
bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of
|
||||
IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the
|
||||
@@ -305,7 +305,8 @@ is per person, shared across their accounts.
|
||||
|
||||
## Presence and badge (**Decided**: both opt-in, per IMVU account)
|
||||
|
||||
- **Badge**: other T5DE users see that this account uses T5DE.
|
||||
- **Badge**: other T5DE users see that this account uses T5DE. The proxy asks the hub which of the accounts it's
|
||||
showing have it (`POST /api/v1/badges`, docs/api.md).
|
||||
- **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't
|
||||
store friend lists, but it sees them when asked (stated in the privacy text).
|
||||
|
||||
@@ -410,7 +411,9 @@ The proxy streams and adds no waiting.
|
||||
- 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
|
||||
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.
|
||||
- **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
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
| `GET /api/v1/catalogs` | (anyone) approved public catalogs; the person's own, any status |
|
||||
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL |
|
||||
| `GET /api/v1/products?q=&creator=` | (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?}` |
|
||||
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
|
||||
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
|
||||
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after. 204 |
|
||||
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
|
||||
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
|
||||
| `PUT /api/v1/library/:id` | get a product (add it) |
|
||||
| `DELETE /api/v1/library/:id` | remove it |
|
||||
@@ -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.
|
||||
|
||||
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
|
||||
parent, image, categories, createdAt, updatedAt}`. `parent`, `image` and `categories` are the latest revision's:
|
||||
`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and
|
||||
`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if
|
||||
unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`).
|
||||
|
||||
### Parent info
|
||||
|
||||
The hub looks up a revision's parent when the revision is registered (after it's stored; an upload never waits for or
|
||||
fails on it) and stores it with the revision (`product_revision.parent_info`: `{found, image, categories, gender,
|
||||
checkedAt}`; `found` is false when IMVU shows no such product publicly). Info another revision got for the same
|
||||
parent in the last 7 days is reused. Revisions still without it (IMVU was unavailable) are looked up at startup and
|
||||
every 10 minutes, 20 at a time.
|
||||
|
||||
### Console (moderators)
|
||||
|
||||
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
|
||||
@@ -85,3 +101,20 @@ The realtime connection also tells the proxy `{"type": "library"}` when the pers
|
||||
From there it's served like any pack.
|
||||
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client.
|
||||
- Library products appear in the client's inventory (see the proxy's README).
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU
|
||||
keeps the room without them; the hub keeps them (`room_decoration`, routes in api.md), and only T5DE users see them.
|
||||
|
||||
- **Saving**: when the room's owner saves the room (`test.setRoomState`) or the client broadcasts a furniture change
|
||||
in it, the proxy takes the hub products out before it goes to IMVU, and `PUT /api/v1/decorations/<instance>` the
|
||||
room's full list of them (`[furniture instance ID, hub product ID, placement]`).
|
||||
- **Loading**: when the client loads an IMVU room (`test.getRoomState`), the proxy `GET`s its decorations and adds
|
||||
them to the room's contents (as local IDs, fetching the products as for outfits); the `hash` tells it whether
|
||||
what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back
|
||||
the same way.
|
||||
- **Changes**: after a `PUT`, every proxy with a chat in that room gets `{"type": "decor", "room": instance}` on the
|
||||
realtime connection and `GET`s again.
|
||||
- Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for
|
||||
adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "hosted_room" (
|
||||
"n" serial PRIMARY KEY NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"room_pid" bigint NOT NULL,
|
||||
"rating" text DEFAULT 'general' NOT NULL,
|
||||
"max_users" integer DEFAULT 10 NOT NULL,
|
||||
"scene" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"scene_hash" text DEFAULT '' NOT NULL,
|
||||
"closed" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD CONSTRAINT "hosted_room_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_owner" ON "hosted_room" USING btree ("owner_id");--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_created" ON "hosted_room" USING btree ("created_at");
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "room_decoration" (
|
||||
"instance_id" text PRIMARY KEY NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"contents" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id");
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "hosted_room_ban" (
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"by_imvu_id" bigint NOT NULL,
|
||||
"until" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_ban_n_imvu_id_pk" PRIMARY KEY("n","imvu_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_favorite" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_favorite_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_image" (
|
||||
"n" integer PRIMARY KEY NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"data" "bytea" NOT NULL,
|
||||
"hash" text NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_rating" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"score" integer NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_rating_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hosted_room_visit" (
|
||||
"user_id" text NOT NULL,
|
||||
"n" integer NOT NULL,
|
||||
"imvu_id" bigint NOT NULL,
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hosted_room_visit_user_id_n_pk" PRIMARY KEY("user_id","n")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "privacy" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "moderators" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room" ADD COLUMN "image_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_ban" ADD CONSTRAINT "hosted_room_ban_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_favorite" ADD CONSTRAINT "hosted_room_favorite_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_image" ADD CONSTRAINT "hosted_room_image_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_rating" ADD CONSTRAINT "hosted_room_rating_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hosted_room_visit" ADD CONSTRAINT "hosted_room_visit_n_hosted_room_n_fk" FOREIGN KEY ("n") REFERENCES "public"."hosted_room"("n") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_rating_room" ON "hosted_room_rating" USING btree ("n");--> statement-breakpoint
|
||||
CREATE INDEX "hosted_room_visit_at" ON "hosted_room_visit" USING btree ("user_id","at");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
|
||||
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
@@ -29,6 +29,41 @@
|
||||
"when": 1793324955738,
|
||||
"tag": "0003_products",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1793347080470,
|
||||
"tag": "0004_parent_info",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1793355138984,
|
||||
"tag": "0005_hosted_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1793357692630,
|
||||
"tag": "0006_decorations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1793364727935,
|
||||
"tag": "0007_hosted_rooms_phase2",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1793372872749,
|
||||
"tag": "0008_product_deletion",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { FileEntry } from "./schema";
|
||||
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -226,6 +227,15 @@ export const audit = pgTable(
|
||||
|
||||
export type FileEntry = { name: string; sha256: string; size: number };
|
||||
|
||||
/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */
|
||||
export type ParentInfo = {
|
||||
found: boolean; // false: IMVU shows no such product publicly
|
||||
image: string | null; // https, on *.imvu.com
|
||||
categories: { id: number; name: string }[]; // outermost first
|
||||
gender: string; // "female", "male" or ""
|
||||
checkedAt: string; // ISO time
|
||||
};
|
||||
|
||||
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
|
||||
export const signingKey = pgTable("signing_key", {
|
||||
id: text("id").primaryKey(), // the JWK's kid
|
||||
@@ -266,6 +276,11 @@ export const product = pgTable(
|
||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
||||
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
|
||||
removedAt: timestamp("removed_at", { withTimezone: true }),
|
||||
removedReason: text("removed_reason"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
@@ -283,6 +298,7 @@ export const productRevision = pgTable(
|
||||
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
||||
rating: text("rating").notNull(),
|
||||
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
||||
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
|
||||
notes: text("notes").notNull().default(""),
|
||||
token: text("token").notNull(), // the hub's t5de-revision token
|
||||
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
||||
@@ -321,3 +337,139 @@ export const library = pgTable(
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
||||
);
|
||||
|
||||
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
|
||||
|
||||
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
|
||||
export type SceneItem = [number, number | string, string];
|
||||
|
||||
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
|
||||
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
|
||||
export const hostedRoom = pgTable(
|
||||
"hosted_room",
|
||||
{
|
||||
n: serial("n").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""), // plain text
|
||||
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
|
||||
rating: text("rating").notNull().default("general"), // "general", "adult"
|
||||
maxUsers: integer("max_users").notNull().default(10),
|
||||
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
|
||||
sceneHash: text("scene_hash").notNull().default(""),
|
||||
closed: boolean("closed").notNull().default(false),
|
||||
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
|
||||
privacy: text("privacy").notNull().default("public"),
|
||||
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
|
||||
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
|
||||
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
|
||||
imageHash: text("image_hash"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
|
||||
export type RoomModerator = { imvuId: number; username: string };
|
||||
|
||||
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
|
||||
dataType: () => "bytea",
|
||||
fromDriver: (v) => Buffer.from(v),
|
||||
});
|
||||
|
||||
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
|
||||
export const hostedRoomImage = pgTable("hosted_room_image", {
|
||||
n: integer("n")
|
||||
.primaryKey()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
|
||||
data: bytea("data").notNull(),
|
||||
hash: text("hash").notNull(), // sha256, hex
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
// IMVU accounts banned from a hosted room; until null: for good.
|
||||
export const hostedRoomBan = pgTable(
|
||||
"hosted_room_ban",
|
||||
{
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
|
||||
until: timestamp("until", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
|
||||
);
|
||||
|
||||
// A person's favorite hosted rooms.
|
||||
export const hostedRoomFavorite = pgTable(
|
||||
"hosted_room_favorite",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] })],
|
||||
);
|
||||
|
||||
// A person's rating of a hosted room, 1-5.
|
||||
export const hostedRoomRating = pgTable(
|
||||
"hosted_room_rating",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
score: integer("score").notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
|
||||
);
|
||||
|
||||
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
|
||||
export const hostedRoomVisit = pgTable(
|
||||
"hosted_room_visit",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
n: integer("n")
|
||||
.notNull()
|
||||
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
|
||||
);
|
||||
|
||||
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
|
||||
|
||||
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
|
||||
export type DecorationItem = [number, string, string];
|
||||
|
||||
// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from
|
||||
// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in
|
||||
// hosted_room.scene instead.
|
||||
export const roomDecoration = pgTable(
|
||||
"room_decoration",
|
||||
{
|
||||
instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "<owner>-<n>"
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
|
||||
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
|
||||
);
|
||||
Reference in new issue
Block a user