Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9c2f32cb | ||
|
|
2b88a52bcb | ||
|
|
58a3e70a63 | ||
|
|
7299e10b08 | ||
|
|
e07a6c6dac | ||
|
|
2cbedd26fc | ||
|
|
ed3a188b82 | ||
|
|
d5cd2be5d0 | ||
|
|
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).
|
||||
@@ -8,11 +8,14 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
|
||||
## What works
|
||||
|
||||
- **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
|
||||
an admin; roles user, moderator, admin.
|
||||
an admin; roles user, moderator, admin. Registration open or closed (new accounts wait for a moderator's
|
||||
approval): an admin's setting in the console, defaulting to the API's `REGISTRATION`.
|
||||
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
|
||||
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
|
||||
(default 10).
|
||||
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub.
|
||||
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub, or
|
||||
if its person verified their age with an ID document and a selfie (Didit, when configured: `AGE_PROVIDERS`, see
|
||||
`apps/api/src/index.ts` and docs/design.md, "Age").
|
||||
- **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the
|
||||
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
|
||||
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
|
||||
@@ -49,4 +52,7 @@ Needs Bun (1.4) and Node (for Next.js).
|
||||
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
|
||||
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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in
|
||||
// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether
|
||||
// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest.
|
||||
//
|
||||
// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session
|
||||
// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook.
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { AgeResult, Provider } from "./index";
|
||||
|
||||
export type DiditConfig = {
|
||||
apiKey: string;
|
||||
workflowId: string;
|
||||
/** The webhook destination's secret_shared_key. */
|
||||
webhookSecret: string;
|
||||
/** Checks started a month at most (what the month's credit pays for). */
|
||||
monthlyLimit?: number;
|
||||
baseURL?: string;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
|
||||
/** How far a webhook's X-Timestamp may be from now. */
|
||||
const timestampTolerance = 300;
|
||||
|
||||
/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */
|
||||
const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]);
|
||||
|
||||
type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] };
|
||||
|
||||
/** Age in whole years on a date, from an ISO birth date. */
|
||||
export function ageOn(birth: string, today: Date): number | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth);
|
||||
if (!m) return undefined;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
let age = today.getUTCFullYear() - y;
|
||||
if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** The age on the session's ID document, or undefined if it has none. */
|
||||
function documentAge(decision: Decision | undefined): number | undefined {
|
||||
const id = decision?.id_verifications?.[0];
|
||||
if (typeof id?.age === "number") return id.age;
|
||||
return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined;
|
||||
}
|
||||
|
||||
/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */
|
||||
function canonical(v: unknown): unknown {
|
||||
if (Array.isArray(v)) return v.map(canonical);
|
||||
if (v && typeof v === "object")
|
||||
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
|
||||
return v;
|
||||
}
|
||||
|
||||
function hmacMatches(secret: string, input: string, signature: string | null): boolean {
|
||||
if (!signature) return false;
|
||||
const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex"));
|
||||
const given = Buffer.from(signature.trim().toLowerCase());
|
||||
return expected.length === given.length && timingSafeEqual(expected, given);
|
||||
}
|
||||
|
||||
export function diditAgeProvider(config: DiditConfig): Provider {
|
||||
const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, "");
|
||||
const fetcher = config.fetch ?? fetch;
|
||||
const call = async (method: string, path: string, body?: unknown) => {
|
||||
const res = await fetcher(base + path, {
|
||||
method,
|
||||
headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
return {
|
||||
id: "didit",
|
||||
label: "ID document and selfie",
|
||||
vendor: "Didit",
|
||||
scope: "person",
|
||||
monthlyLimit: config.monthlyLimit,
|
||||
|
||||
async start(subject, { returnURL }) {
|
||||
const s = (await call("POST", "/v3/session/", {
|
||||
workflow_id: config.workflowId,
|
||||
vendor_data: subject.userId,
|
||||
callback: returnURL,
|
||||
})) as { session_id: string; url: string };
|
||||
return { kind: "redirect", url: s.url, ref: s.session_id };
|
||||
},
|
||||
|
||||
async webhook(request) {
|
||||
const raw = await request.text();
|
||||
const timestamp = Number(request.headers.get("x-timestamp"));
|
||||
if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook");
|
||||
const body = JSON.parse(raw) as {
|
||||
webhook_type?: string;
|
||||
session_id?: string;
|
||||
status?: string;
|
||||
decision?: Decision;
|
||||
};
|
||||
// The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header
|
||||
// during a rotation.
|
||||
const v2 = JSON.stringify(canonical(body));
|
||||
const signed = ["", "-previous"].some(
|
||||
(suffix) =>
|
||||
hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) ||
|
||||
hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)),
|
||||
);
|
||||
if (!signed) throw new Error("bad signature");
|
||||
|
||||
if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null;
|
||||
if (!body.session_id || !body.status) return null;
|
||||
const ref = body.session_id;
|
||||
if (failedStatuses.has(body.status)) return { ref, result: "failed" };
|
||||
if (body.status !== "Approved") return null;
|
||||
const decision =
|
||||
body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision);
|
||||
const age = documentAge(decision);
|
||||
if (age === undefined) return { ref, result: "failed" };
|
||||
const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref };
|
||||
return { ref, result };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function imvuAgeProvider(imvu: ImvuClient): Provider {
|
||||
return {
|
||||
id: "imvu",
|
||||
label: "IMVU's age verification or Access Pass",
|
||||
vendor: "IMVU",
|
||||
scope: "imvu_account",
|
||||
start: async (subject) => ({ kind: "done", result: await check(subject) }),
|
||||
recheck: check,
|
||||
|
||||
+80
-17
@@ -1,7 +1,7 @@
|
||||
// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface,
|
||||
// results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
|
||||
// See docs/design.md, "Age".
|
||||
import { and, eq, gt, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
|
||||
import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
|
||||
|
||||
const { ageCheck } = schema;
|
||||
|
||||
@@ -27,13 +27,19 @@ export type Started =
|
||||
|
||||
export type Provider = {
|
||||
id: string;
|
||||
/** Shown to people. */
|
||||
/** Shown to people: the way of checking, and who does it. */
|
||||
label: string;
|
||||
vendor: string;
|
||||
/** Whether it checks a person, or an IMVU account. */
|
||||
scope: "person" | "imvu_account";
|
||||
/** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */
|
||||
monthlyLimit?: number;
|
||||
start(subject: Subject, options: { returnURL: string }): Promise<Started>;
|
||||
/** Finishes a check from the provider's webhook; throws if the request isn't really from the provider. */
|
||||
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" }>;
|
||||
/**
|
||||
* A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws
|
||||
* if the request isn't really from the provider.
|
||||
*/
|
||||
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>;
|
||||
/** Finishes a check the browser completed (wallets): the browser's response for ref. */
|
||||
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
|
||||
/** Checks a subject again, for answers that can change. */
|
||||
@@ -41,6 +47,12 @@ export type Provider = {
|
||||
};
|
||||
|
||||
export class AgeError extends Error {}
|
||||
/** A check that can't be started now; the message is for the person. */
|
||||
export class AgeRefused extends Error {}
|
||||
|
||||
/** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */
|
||||
export const attemptsPerPerson = 3;
|
||||
const attemptWindow = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
|
||||
|
||||
@@ -56,7 +68,7 @@ export class AgeService {
|
||||
|
||||
/** The providers in use, for the website. */
|
||||
list() {
|
||||
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope }));
|
||||
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope }));
|
||||
}
|
||||
|
||||
provider(id: string): Provider {
|
||||
@@ -87,13 +99,25 @@ export class AgeService {
|
||||
await this.db.insert(ageCheck).values({ id: randomID(), ...values });
|
||||
}
|
||||
|
||||
/** Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. */
|
||||
/**
|
||||
* Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose
|
||||
* check is unfinished goes back to it (providers give the same session again); new checks of a person are
|
||||
* limited, per person and by the provider's monthly allowance.
|
||||
*/
|
||||
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
|
||||
const p = this.provider(providerId);
|
||||
const ofPerson = subject.imvuId === undefined;
|
||||
const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined;
|
||||
if (ofPerson) {
|
||||
if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified.");
|
||||
if (!pending) await this.checkLimits(subject.userId, p);
|
||||
}
|
||||
const started = await p.start(subject, { returnURL });
|
||||
if (started.kind === "done") {
|
||||
await this.record(subject, p.id, started.result);
|
||||
} else {
|
||||
} else if (pending?.providerRef !== started.ref) {
|
||||
// An unfinished check the provider replaced with a new one (it expired there) won't be answered.
|
||||
if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id));
|
||||
await this.db.insert(ageCheck).values({
|
||||
id: randomID(),
|
||||
userId: subject.userId,
|
||||
@@ -106,11 +130,55 @@ export class AgeService {
|
||||
return started;
|
||||
}
|
||||
|
||||
/** Finishes a pending check (from a webhook or the browser). */
|
||||
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
|
||||
private async pendingCheck(userId: string, provider: string) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(ageCheck)
|
||||
.where(
|
||||
and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
private async personAdult(userId: string) {
|
||||
const now = new Date();
|
||||
return (await this.personChecks(userId)).some(
|
||||
(c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkLimits(userId: string, p: Provider) {
|
||||
const started = async (...where: Parameters<typeof and>) =>
|
||||
(await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n;
|
||||
const since = new Date(Date.now() - attemptWindow);
|
||||
if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson)
|
||||
throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`);
|
||||
if (p.monthlyLimit !== undefined) {
|
||||
const now = new Date();
|
||||
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit)
|
||||
throw new AgeRefused("This month's ID checks are used up. Try again next month.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also
|
||||
* one already answered, since the provider can change its answer (say, after a manual review). An adult answer
|
||||
* taken back is revoked.
|
||||
*/
|
||||
async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(ageCheck)
|
||||
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref)));
|
||||
if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check");
|
||||
const done =
|
||||
result === "failed"
|
||||
? { status: "failed", overEighteen: false, checkedAt: new Date() }
|
||||
? {
|
||||
status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed",
|
||||
overEighteen: false,
|
||||
checkedAt: new Date(),
|
||||
}
|
||||
: {
|
||||
status: "complete",
|
||||
overEighteen: result.overEighteen,
|
||||
@@ -118,13 +186,8 @@ export class AgeService {
|
||||
checkedAt: new Date(),
|
||||
expiresAt: result.expiresAt ?? null,
|
||||
};
|
||||
const rows = await this.db
|
||||
.update(ageCheck)
|
||||
.set(done)
|
||||
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref), eq(ageCheck.status, "pending")))
|
||||
.returning();
|
||||
if (rows.length === 0) throw new AgeError("no such pending check");
|
||||
return rows[0]!;
|
||||
const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
|
||||
|
||||
+1997
-57
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
|
||||
import { count, schema, type Database } from "@t5de/db";
|
||||
import { Settings } from "./settings";
|
||||
|
||||
export type AuthConfig = {
|
||||
/** The API's own address, e.g. https://hub-api.t5de.dev. */
|
||||
@@ -15,12 +16,15 @@ export type AuthConfig = {
|
||||
secret: string;
|
||||
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
|
||||
rateLimit?: boolean;
|
||||
/** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */
|
||||
settings?: Settings;
|
||||
};
|
||||
|
||||
/** The proxy's client ID in device sign-in. */
|
||||
export const proxyClientID = "t5de-proxy";
|
||||
|
||||
export function createAuth(db: Database, config: AuthConfig) {
|
||||
const settings = config.settings ?? new Settings(db);
|
||||
return betterAuth({
|
||||
baseURL: config.apiURL,
|
||||
basePath: "/api/auth",
|
||||
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
|
||||
user: {
|
||||
additionalFields: {
|
||||
linkCap: { type: "number", defaultValue: 10, input: false },
|
||||
approved: { type: "boolean", defaultValue: true, input: false },
|
||||
},
|
||||
},
|
||||
// Proxies stay signed in for months; a session is extended when it's used.
|
||||
@@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) {
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
// The first account runs the hub.
|
||||
// The first account runs the hub; with registration closed, the others wait for approval.
|
||||
before: async (u) => {
|
||||
const [row] = await db.select({ n: count() }).from(schema.user);
|
||||
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
|
||||
const first = (row?.n ?? 0) === 0;
|
||||
const approved = first || (await settings.registration()) === "open";
|
||||
return { data: { ...u, role: first ? "admin" : "user", approved } };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// File blocks (docs/products.md): moderation blocks a file network-wide by its sha256, so a removed product can't
|
||||
// come back under another name. Revisions with a blocked file can't be registered, products using it are removed,
|
||||
// and the catalogs hosting it are told to delete it. The routes in app.ts check permissions, audit and notify.
|
||||
import { and, desc, eq, inArray, isNull, lt, sql, schema, type Database } from "@t5de/db";
|
||||
import { ProductError } from "./products";
|
||||
|
||||
const { catalogChange, fileBlock, hosting, product, productRevision, user } = schema;
|
||||
|
||||
export const isSha256 = (s: string) => /^[0-9a-f]{64}$/.test(s);
|
||||
|
||||
/** A product with a revision using a file. */
|
||||
export type FileUse = {
|
||||
id: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
creatorUsername: string | null;
|
||||
removed: boolean;
|
||||
revisions: number[];
|
||||
};
|
||||
|
||||
export class Blocks {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async get(sha256: string) {
|
||||
const [b] = await this.db
|
||||
.select({ block: fileBlock, by: user.username })
|
||||
.from(fileBlock)
|
||||
.leftJoin(user, eq(user.id, fileBlock.byId))
|
||||
.where(eq(fileBlock.sha256, sha256));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Which of these files are blocked. */
|
||||
async blocked(sha256s: string[]): Promise<string[]> {
|
||||
if (sha256s.length === 0) return [];
|
||||
const rows = await this.db.select({ sha256: fileBlock.sha256 }).from(fileBlock).where(inArray(fileBlock.sha256, sha256s));
|
||||
return rows.map((r) => r.sha256);
|
||||
}
|
||||
|
||||
/** The products with a revision using a file (what blocking it would remove), newest first. */
|
||||
async usedBy(sha256: string): Promise<FileUse[]> {
|
||||
const rows = await this.db
|
||||
.select({ p: product, creatorUsername: user.username, number: productRevision.number })
|
||||
.from(productRevision)
|
||||
.innerJoin(product, eq(product.id, productRevision.productId))
|
||||
.leftJoin(user, eq(user.id, product.creatorId))
|
||||
.where(sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`)
|
||||
.orderBy(desc(product.createdAt), productRevision.number);
|
||||
const out = new Map<string, FileUse>();
|
||||
for (const r of rows) {
|
||||
if (r.p.deletedAt && !r.p.removedAt) continue; // deleted by its creator: nothing to remove
|
||||
const u = out.get(r.p.id) ?? {
|
||||
id: r.p.id,
|
||||
name: r.p.name,
|
||||
creatorId: r.p.creatorId,
|
||||
creatorUsername: r.creatorUsername,
|
||||
removed: r.p.removedAt !== null,
|
||||
revisions: [],
|
||||
};
|
||||
u.revisions.push(r.number);
|
||||
out.set(r.p.id, u);
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks a file: removes the products using it (those not removed already, which are returned) and tells the
|
||||
* catalogs hosting it to delete it (a `block` change).
|
||||
*/
|
||||
async block(sha256: string, reason: string, byId: string): Promise<FileUse[]> {
|
||||
if (!isSha256(sha256)) throw new ProductError(400, "invalid", "That isn't a file's sha256 (64 hex digits).");
|
||||
const uses = await this.usedBy(sha256);
|
||||
const ids = uses.map((u) => u.id);
|
||||
const removed = uses.filter((u) => !u.removed);
|
||||
await this.db.transaction(async (tx) => {
|
||||
const [made] = await tx.insert(fileBlock).values({ sha256, reason, byId }).onConflictDoNothing().returning();
|
||||
if (!made) throw new ProductError(409, "blocked", "That file is blocked already.");
|
||||
if (removed.length)
|
||||
await tx
|
||||
.update(product)
|
||||
.set({ removedAt: new Date(), removedReason: reason })
|
||||
.where(and(inArray(product.id, removed.map((u) => u.id)), isNull(product.removedAt)));
|
||||
if (ids.length) {
|
||||
const hosts = await tx
|
||||
.selectDistinct({ catalogId: hosting.catalogId })
|
||||
.from(hosting)
|
||||
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
|
||||
.where(and(inArray(hosting.productId, ids), sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`));
|
||||
if (hosts.length) await tx.insert(catalogChange).values(hosts.map((h) => ({ catalogId: h.catalogId, data: { type: "block", sha256 } })));
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Unblocks a file. Products it removed stay removed (moderators restore them one by one); catalogs aren't told,
|
||||
* since they deleted the file and get it again only with a new upload. */
|
||||
async unblock(sha256: string) {
|
||||
const [gone] = await this.db.delete(fileBlock).where(eq(fileBlock.sha256, sha256)).returning();
|
||||
if (!gone) throw new ProductError(404, "not_found", "That file isn't blocked.");
|
||||
return gone;
|
||||
}
|
||||
|
||||
/** Blocks, newest first. */
|
||||
async list(opts: { before?: Date; limit?: number } = {}) {
|
||||
return this.db
|
||||
.select({ block: fileBlock, by: user.username })
|
||||
.from(fileBlock)
|
||||
.leftJoin(user, eq(user.id, fileBlock.byId))
|
||||
.where(opts.before ? lt(fileBlock.createdAt, opts.before) : undefined)
|
||||
.orderBy(desc(fileBlock.createdAt))
|
||||
.limit(opts.limit ?? 50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Cases (docs/console.md): one queue for everything someone has to decide. Reports, rating reviews, removal
|
||||
// requests, catalogs' deletion requests and appeals. The routes in app.ts check who may open one and carry out a
|
||||
// decision's effects; this keeps the cases, their threads, and who may see and decide them.
|
||||
import { and, count, desc, eq, gt, inArray, lt, or, sql, schema, type AuditTags, type Database } from "@t5de/db";
|
||||
import { ProductError } from "./products";
|
||||
import { Permissions, type Permission, type Who } from "./permissions";
|
||||
|
||||
const { caseNote, catalog, hosting, moderationCase, product, user } = schema;
|
||||
|
||||
export const caseKinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
|
||||
export type CaseKind = (typeof caseKinds)[number];
|
||||
export type Case = typeof moderationCase.$inferSelect;
|
||||
|
||||
/** The permission that decides each kind of case (moderators hold them hub-wide). */
|
||||
const decidedWith: Record<CaseKind, Permission> = {
|
||||
report: "case:resolve",
|
||||
"rating-review": "product:rate-lower",
|
||||
"removal-request": "product:remove",
|
||||
"catalog-deletion": "catalog:delete-review",
|
||||
appeal: "case:resolve",
|
||||
};
|
||||
|
||||
/** The actions people can appeal, and whom they affect: a product's creator, a catalog's owner, or the person. */
|
||||
export const appealable: Record<string, "creator" | "owner" | "person"> = {
|
||||
"product.remove": "creator",
|
||||
"product.edit": "creator",
|
||||
"product.lock": "creator",
|
||||
"product.revert": "creator",
|
||||
"catalog.delist": "creator",
|
||||
"catalog.suspend": "owner",
|
||||
"catalog.reject": "owner",
|
||||
"catalog.member_ban": "person",
|
||||
"catalog.member_decline": "person",
|
||||
};
|
||||
|
||||
/** Reports a person may open in an hour. */
|
||||
export const reportsPerHour = 10;
|
||||
|
||||
export class Cases {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly perms: Permissions,
|
||||
) {}
|
||||
|
||||
async get(id: number): Promise<Case> {
|
||||
const [c] = await this.db.select().from(moderationCase).where(eq(moderationCase.id, id));
|
||||
if (!c) throw new ProductError(404, "not_found", "No such case.");
|
||||
return c;
|
||||
}
|
||||
|
||||
/** The open case of a kind about a resource (one each), if any; with `openedBy`, only theirs. */
|
||||
async findOpen(kind: CaseKind, tags: AuditTags, openedBy?: string): Promise<Case | undefined> {
|
||||
const conds = [eq(moderationCase.kind, kind), eq(moderationCase.status, "open")];
|
||||
for (const [k, v] of Object.entries(tags)) if (v) conds.push(sql`${moderationCase.tags}->>${k} = ${v}`);
|
||||
if (openedBy) conds.push(eq(moderationCase.openedBy, openedBy));
|
||||
const [c] = await this.db.select().from(moderationCase).where(and(...conds)).limit(1);
|
||||
return c;
|
||||
}
|
||||
|
||||
async open(kind: CaseKind, openedBy: string, tags: AuditTags, reason: string, data: Record<string, unknown> = {}) {
|
||||
const [c] = await this.db
|
||||
.insert(moderationCase)
|
||||
.values({ kind, openedBy, tags, reason: reason.trim().slice(0, 2000), data })
|
||||
.returning();
|
||||
return c!;
|
||||
}
|
||||
|
||||
/** A report: at most reportsPerHour an hour, and one open report per person and resource. */
|
||||
async report(openedBy: string, tags: AuditTags, reason: string) {
|
||||
if (!reason.trim()) throw new ProductError(400, "reason_needed", "Say what's wrong.");
|
||||
const since = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const [recent] = await this.db
|
||||
.select({ n: count() })
|
||||
.from(moderationCase)
|
||||
.where(and(eq(moderationCase.kind, "report"), eq(moderationCase.openedBy, openedBy), gt(moderationCase.createdAt, since)));
|
||||
if ((recent?.n ?? 0) >= reportsPerHour)
|
||||
throw new ProductError(429, "rate_limited", "That's a lot of reports. Try again in an hour.");
|
||||
if (await this.findOpen("report", tags, openedBy))
|
||||
throw new ProductError(409, "already_reported", "You reported that already; moderators will look at it.");
|
||||
return this.open("report", openedBy, tags, reason);
|
||||
}
|
||||
|
||||
/** The catalogs hosting a product (whose teams decide reports about it). */
|
||||
private async hostsOf(productId: string): Promise<string[]> {
|
||||
const rows = await this.db.selectDistinct({ id: hosting.catalogId }).from(hosting).where(eq(hosting.productId, productId));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone may decide a case: moderators by the kind's permission (an appeal by a different moderator than
|
||||
* the one who acted, unless an admin, flagged in the audit log); reports about a product also its hosting catalogs'
|
||||
* teams (case:resolve there).
|
||||
*/
|
||||
async canDecide(who: Who, c: Case): Promise<boolean> {
|
||||
const perm = decidedWith[c.kind as CaseKind];
|
||||
if (!perm) return false;
|
||||
if (this.perms.hub(who).has(perm)) return !(c.kind === "appeal" && c.data.actorId === who.id && who.role !== "admin");
|
||||
if (c.kind === "report" && c.tags.product)
|
||||
for (const id of await this.hostsOf(c.tags.product))
|
||||
if (await this.perms.can(who, "case:resolve", { type: "catalog", id })) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether someone may see a case: its opener, its assignee, those deciding it, and (but for reports, which keep
|
||||
* who reported private) those with case:read at what it's about. */
|
||||
async canSee(who: Who, c: Case): Promise<boolean> {
|
||||
if (c.openedBy === who.id || c.assignee === who.id) return true;
|
||||
if (this.perms.hub(who).has("case:read") || (await this.canDecide(who, c))) return true;
|
||||
if (c.kind === "report") return false;
|
||||
if (c.tags.catalog && (await this.perms.can(who, "case:read", { type: "catalog", id: c.tags.catalog }))) return true;
|
||||
if (c.tags.product && (await this.perms.can(who, "case:read", { type: "product", id: c.tags.product }))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The cases a person may see (as canSee), newest first. */
|
||||
async list(who: Who, f: { status?: string; kind?: string; mine?: boolean; before?: number; limit?: number }) {
|
||||
const conds = [];
|
||||
if (f.mine) conds.push(eq(moderationCase.openedBy, who.id));
|
||||
else if (!this.perms.hub(who).has("case:read")) {
|
||||
const teams = await this.perms.teamCatalogs(who.id);
|
||||
const own = await this.perms.ownProducts(who.id);
|
||||
const visible = [eq(moderationCase.openedBy, who.id), eq(moderationCase.assignee, who.id)];
|
||||
if (teams.length) {
|
||||
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'catalog'`, teams))!);
|
||||
visible.push(
|
||||
and(
|
||||
eq(moderationCase.kind, "report"),
|
||||
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
if (own.length)
|
||||
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'product'`, own))!);
|
||||
conds.push(or(...visible)!);
|
||||
}
|
||||
if (f.status) conds.push(eq(moderationCase.status, f.status));
|
||||
if (f.kind) conds.push(eq(moderationCase.kind, f.kind));
|
||||
if (f.before) conds.push(lt(moderationCase.id, f.before));
|
||||
return this.db
|
||||
.select()
|
||||
.from(moderationCase)
|
||||
.where(conds.length ? and(...conds) : undefined)
|
||||
.orderBy(desc(moderationCase.id))
|
||||
.limit(Math.max(1, Math.min(f.limit ?? 50, 200)));
|
||||
}
|
||||
|
||||
/** People's usernames and resources' names for cases (to show and link them). */
|
||||
async names(cases: Case[]) {
|
||||
const people = new Set<string>();
|
||||
const products = new Set<string>();
|
||||
const catalogs = new Set<string>();
|
||||
for (const c of cases) {
|
||||
for (const p of [c.openedBy, c.assignee, c.decidedBy, c.tags.person]) if (p) people.add(p);
|
||||
if (c.tags.product) products.add(c.tags.product);
|
||||
if (c.tags.catalog) catalogs.add(c.tags.catalog);
|
||||
}
|
||||
const [ps, prs, cs] = await Promise.all([
|
||||
people.size
|
||||
? this.db.select({ id: user.id, name: user.displayUsername, username: user.username }).from(user).where(inArray(user.id, [...people]))
|
||||
: [],
|
||||
products.size ? this.db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, [...products])) : [],
|
||||
catalogs.size ? this.db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, [...catalogs])) : [],
|
||||
]);
|
||||
const names: Record<string, string> = {};
|
||||
for (const p of ps) names[p.id] = p.name ?? p.username ?? "";
|
||||
for (const x of [...prs, ...cs]) names[x.id] = x.name;
|
||||
return names;
|
||||
}
|
||||
|
||||
async decide(c: Case, by: string, status: "resolved" | "declined", resolution: string) {
|
||||
if (c.status !== "open") throw new ProductError(409, "decided", "That case is decided already.");
|
||||
const [updated] = await this.db
|
||||
.update(moderationCase)
|
||||
.set({ status, resolution: resolution.trim().slice(0, 2000), decidedBy: by, decidedAt: new Date() })
|
||||
.where(and(eq(moderationCase.id, c.id), eq(moderationCase.status, "open")))
|
||||
.returning();
|
||||
if (!updated) throw new ProductError(409, "decided", "That case is decided already.");
|
||||
return updated;
|
||||
}
|
||||
|
||||
async assign(c: Case, assignee: string | null) {
|
||||
const [updated] = await this.db.update(moderationCase).set({ assignee }).where(eq(moderationCase.id, c.id)).returning();
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async notes(c: Case, internalToo: boolean) {
|
||||
return this.db
|
||||
.select({
|
||||
id: caseNote.id,
|
||||
by: user.displayUsername,
|
||||
byId: caseNote.byId,
|
||||
body: caseNote.body,
|
||||
internal: caseNote.internal,
|
||||
at: caseNote.createdAt,
|
||||
})
|
||||
.from(caseNote)
|
||||
.leftJoin(user, eq(user.id, caseNote.byId))
|
||||
.where(and(eq(caseNote.caseId, c.id), internalToo ? undefined : eq(caseNote.internal, false)))
|
||||
.orderBy(caseNote.id);
|
||||
}
|
||||
|
||||
async addNote(c: Case, byId: string, body: string, internal: boolean) {
|
||||
body = body.trim().slice(0, 4000);
|
||||
if (!body) throw new ProductError(400, "invalid", "Write something.");
|
||||
await this.db.insert(caseNote).values({ caseId: c.id, byId, body, internal });
|
||||
await this.db.update(moderationCase).set({ updatedAt: new Date() }).where(eq(moderationCase.id, c.id));
|
||||
}
|
||||
|
||||
/** Open cases a person could decide (for the console's to-dos): every one for moderators, else reports about
|
||||
* products their catalogs host. */
|
||||
async openToDecide(who: Who): Promise<number> {
|
||||
const open = eq(moderationCase.status, "open");
|
||||
if (this.perms.hub(who).has("case:resolve")) {
|
||||
const [row] = await this.db.select({ n: count() }).from(moderationCase).where(open);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
const teams = await this.perms.teamCatalogs(who.id);
|
||||
if (!teams.length) return 0;
|
||||
const [row] = await this.db
|
||||
.select({ n: count() })
|
||||
.from(moderationCase)
|
||||
.where(
|
||||
and(
|
||||
open,
|
||||
eq(moderationCase.kind, "report"),
|
||||
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
|
||||
),
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host,
|
||||
// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do.
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db";
|
||||
import { ProductError } from "./products";
|
||||
|
||||
const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } =
|
||||
schema;
|
||||
|
||||
export type Catalog = typeof catalog.$inferSelect;
|
||||
export type MemberStatus = "requested" | "member" | "banned";
|
||||
|
||||
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
|
||||
// Invite codes: 10 letters and digits without look-alikes.
|
||||
const inviteCode = () => {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join("");
|
||||
};
|
||||
|
||||
const plain = (s: unknown, max: number) => {
|
||||
const v = typeof s === "string" ? s.trim() : "";
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
|
||||
return v;
|
||||
};
|
||||
|
||||
/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */
|
||||
export function catalogURL(input: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw new ProductError(400, "invalid", "That address isn't a URL.");
|
||||
}
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
|
||||
throw new ProductError(400, "invalid", "Catalogs need an https address.");
|
||||
return url.origin + url.pathname.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export type CatalogSettings = {
|
||||
name?: string;
|
||||
url?: string;
|
||||
contact?: string;
|
||||
visibility?: "public" | "private";
|
||||
membership?: "open" | "approval" | "invite";
|
||||
maxRating?: "general" | "adult";
|
||||
uploads?: "anyone" | "members" | "team";
|
||||
mirrors?: "auto" | "approve" | "off";
|
||||
};
|
||||
|
||||
export class Catalogs {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
/** Whether a catalog answers at an address (its /health); checked when its address changes. */
|
||||
private readonly healthy: (url: string) => Promise<boolean> = defaultHealthy,
|
||||
) {}
|
||||
|
||||
async get(id: string): Promise<Catalog> {
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id));
|
||||
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */
|
||||
async update(id: string, input: CatalogSettings) {
|
||||
const c = await this.get(id);
|
||||
const set: Partial<Catalog> = {};
|
||||
if (input.name !== undefined) {
|
||||
const name = plain(input.name, 64);
|
||||
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
|
||||
set.name = name;
|
||||
}
|
||||
if (input.contact !== undefined) {
|
||||
const contact = plain(input.contact, 200);
|
||||
if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed.");
|
||||
set.contact = contact;
|
||||
}
|
||||
if (input.url !== undefined) {
|
||||
const url = catalogURL(input.url);
|
||||
if (url !== c.url) {
|
||||
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url));
|
||||
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
|
||||
if (!(await this.healthy(url)))
|
||||
throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?");
|
||||
set.url = url;
|
||||
}
|
||||
}
|
||||
for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const)
|
||||
if (input[k] !== undefined) (set as Record<string, unknown>)[k] = input[k];
|
||||
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const [k, v] of Object.entries(set)) {
|
||||
const before = (c as Record<string, unknown>)[k];
|
||||
if (before !== v) changed[k] = { from: before, to: v };
|
||||
}
|
||||
if (Object.keys(changed).length === 0) return { catalog: c, changed };
|
||||
const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning();
|
||||
return { catalog: updated!, changed };
|
||||
}
|
||||
|
||||
/** A new key for the catalog; the old one stops working at once. */
|
||||
async rotateKey(id: string): Promise<string> {
|
||||
await this.get(id);
|
||||
const key = randomKey();
|
||||
await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id));
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Suspends or reinstates an approved catalog; tells it through its changes feed. */
|
||||
async setSuspended(id: string, suspended: boolean, reason: string) {
|
||||
const c = await this.get(id);
|
||||
if (suspended && c.status !== "approved")
|
||||
throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended.");
|
||||
if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended.");
|
||||
const status = suspended ? "suspended" : "approved";
|
||||
const [updated] = await this.db
|
||||
.update(catalog)
|
||||
.set({ status, statusReason: suspended ? reason : "" })
|
||||
.where(eq(catalog.id, id))
|
||||
.returning();
|
||||
await this.change(id, { type: "status", status });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Deletes a catalog (after a moderator accepted its deletion request): it stays on record as deleted, nothing
|
||||
* is available from it, and it learns so from its changes feed. */
|
||||
async markDeleted(id: string, reason: string) {
|
||||
await this.get(id);
|
||||
const [updated] = await this.db
|
||||
.update(catalog)
|
||||
.set({ status: "deleted", statusReason: reason })
|
||||
.where(eq(catalog.id, id))
|
||||
.returning();
|
||||
await this.change(id, { type: "status", status: "deleted" });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
// ---- teams ----
|
||||
|
||||
async addRole(catalogId: string, personId: string, role: string, by: string) {
|
||||
const c = await this.get(catalogId);
|
||||
if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner.");
|
||||
await this.db
|
||||
.insert(roleAssignment)
|
||||
.values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeRole(catalogId: string, personId: string, role: string) {
|
||||
const gone = await this.db
|
||||
.delete(roleAssignment)
|
||||
.where(
|
||||
and(
|
||||
eq(roleAssignment.userId, personId),
|
||||
eq(roleAssignment.role, role),
|
||||
eq(roleAssignment.scopeType, "catalog"),
|
||||
eq(roleAssignment.scopeId, catalogId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here.");
|
||||
}
|
||||
|
||||
// ---- members ----
|
||||
|
||||
async membership(catalogId: string, personId: string): Promise<MemberStatus | undefined> {
|
||||
const [m] = await this.db
|
||||
.select({ status: catalogMember.status })
|
||||
.from(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)));
|
||||
return m?.status as MemberStatus | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which
|
||||
* also lets them into approval catalogs at once). Returns their status.
|
||||
*/
|
||||
async join(catalogId: string, personId: string, invite?: string): Promise<MemberStatus> {
|
||||
const c = await this.get(catalogId);
|
||||
if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
|
||||
const now = await this.membership(catalogId, personId);
|
||||
if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it.");
|
||||
if (now === "member") return "member";
|
||||
let status: MemberStatus;
|
||||
let usedInvite: string | null = null;
|
||||
if (invite) {
|
||||
const updated = await this.db
|
||||
.update(catalogInvite)
|
||||
.set({ uses: sql`${catalogInvite.uses} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(catalogInvite.code, invite),
|
||||
eq(catalogInvite.catalogId, catalogId),
|
||||
isNull(catalogInvite.revokedAt),
|
||||
or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())),
|
||||
or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked.");
|
||||
status = "member";
|
||||
usedInvite = invite;
|
||||
} else if (c.membership === "open") status = "member";
|
||||
else if (c.membership === "approval") status = "requested";
|
||||
else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only.");
|
||||
await this.db
|
||||
.insert(catalogMember)
|
||||
.values({ catalogId, userId: personId, status, invite: usedInvite })
|
||||
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } });
|
||||
return status;
|
||||
}
|
||||
|
||||
/** A person leaves (or withdraws their request). A ban stays. */
|
||||
async leave(catalogId: string, personId: string) {
|
||||
await this.db
|
||||
.delete(catalogMember)
|
||||
.where(
|
||||
and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`),
|
||||
);
|
||||
}
|
||||
|
||||
async members(catalogId: string, status?: MemberStatus) {
|
||||
const conds = [eq(catalogMember.catalogId, catalogId)];
|
||||
if (status) conds.push(eq(catalogMember.status, status));
|
||||
const rows = await this.db
|
||||
.select({
|
||||
personId: catalogMember.userId,
|
||||
username: user.username,
|
||||
name: user.displayUsername,
|
||||
status: catalogMember.status,
|
||||
invite: catalogMember.invite,
|
||||
since: catalogMember.updatedAt,
|
||||
})
|
||||
.from(catalogMember)
|
||||
.innerJoin(user, eq(user.id, catalogMember.userId))
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(catalogMember.updatedAt))
|
||||
.limit(500);
|
||||
return rows.map((r) => ({
|
||||
personId: r.personId,
|
||||
username: r.name ?? r.username ?? "",
|
||||
status: r.status as MemberStatus,
|
||||
invited: r.invite !== null,
|
||||
since: r.since.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async memberCounts(catalogId: string) {
|
||||
const rows = await this.db
|
||||
.select({ status: catalogMember.status, n: count() })
|
||||
.from(catalogMember)
|
||||
.where(eq(catalogMember.catalogId, catalogId))
|
||||
.groupBy(catalogMember.status);
|
||||
const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0;
|
||||
return { members: get("member"), requests: get("requested"), banned: get("banned") };
|
||||
}
|
||||
|
||||
/** The team sets a person's membership: let them in, or ban them. */
|
||||
async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) {
|
||||
await this.get(catalogId);
|
||||
await this.db
|
||||
.insert(catalogMember)
|
||||
.values({ catalogId, userId: personId, status, decidedBy: by })
|
||||
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } });
|
||||
}
|
||||
|
||||
async removeMember(catalogId: string, personId: string) {
|
||||
const gone = await this.db
|
||||
.delete(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)))
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be.");
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) {
|
||||
await this.get(catalogId);
|
||||
const [inv] = await this.db
|
||||
.insert(catalogInvite)
|
||||
.values({
|
||||
code: inviteCode(),
|
||||
catalogId,
|
||||
createdBy: by,
|
||||
maxUses: opts.maxUses ?? null,
|
||||
expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null,
|
||||
})
|
||||
.returning();
|
||||
return inv!;
|
||||
}
|
||||
|
||||
async invites(catalogId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(catalogInvite)
|
||||
.where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt)))
|
||||
.orderBy(desc(catalogInvite.createdAt));
|
||||
}
|
||||
|
||||
async revokeInvite(catalogId: string, code: string) {
|
||||
const r = await this.db
|
||||
.update(catalogInvite)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt)))
|
||||
.returning();
|
||||
if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation.");
|
||||
}
|
||||
|
||||
// ---- what it hosts ----
|
||||
|
||||
/** The products a catalog hosts, newest first, with the revisions it hosts of each. */
|
||||
async hosted(catalogId: string) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
rating: product.rating,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
removed: product.removedAt,
|
||||
deleted: product.deletedAt,
|
||||
revisions: sql<number[]>`array_agg(${hosting.number} order by ${hosting.number})`,
|
||||
since: sql<string>`min(${hosting.createdAt})`,
|
||||
})
|
||||
.from(hosting)
|
||||
.innerJoin(product, eq(product.id, hosting.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(hosting.catalogId, catalogId))
|
||||
.groupBy(product.id, user.id)
|
||||
.orderBy(desc(sql`min(${hosting.createdAt})`))
|
||||
.limit(500);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
rating: r.rating,
|
||||
creator: r.creator ?? r.creatorUsername ?? "",
|
||||
removed: r.removed !== null,
|
||||
deleted: r.deleted !== null,
|
||||
revisions: r.revisions,
|
||||
since: new Date(r.since).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async hostedCounts(catalogId: string) {
|
||||
const [r] = await this.db
|
||||
.select({
|
||||
products: sql<number>`count(distinct ${hosting.productId})::int`,
|
||||
revisions: sql<number>`count(*)::int`,
|
||||
bytes: sql<number>`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`,
|
||||
})
|
||||
.from(hosting)
|
||||
.innerJoin(
|
||||
productRevision,
|
||||
and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)),
|
||||
)
|
||||
.where(eq(hosting.catalogId, catalogId));
|
||||
return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) };
|
||||
}
|
||||
|
||||
/** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */
|
||||
async delist(catalogId: string, productId: string) {
|
||||
const gone = await this.db
|
||||
.delete(hosting)
|
||||
.where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId)))
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product.");
|
||||
const revs = await this.db
|
||||
.select({ number: productRevision.number, files: productRevision.files })
|
||||
.from(productRevision)
|
||||
.where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number))));
|
||||
for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files });
|
||||
return gone.map((g) => g.number);
|
||||
}
|
||||
|
||||
// ---- the changes feed ----
|
||||
|
||||
async change(catalogId: string, data: Record<string, unknown>) {
|
||||
await this.db.insert(catalogChange).values({ catalogId, data });
|
||||
}
|
||||
|
||||
/** Changes after a cursor, oldest first (at most 500 at a time). */
|
||||
async changes(catalogId: string, after: number) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(catalogChange)
|
||||
.where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after)))
|
||||
.orderBy(asc(catalogChange.id))
|
||||
.limit(500);
|
||||
return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after };
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
+56
-3
@@ -4,9 +4,19 @@
|
||||
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
|
||||
// WEB_URL the website's address (default http://localhost:4100)
|
||||
// PORT default 4101
|
||||
// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console
|
||||
// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit
|
||||
// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET
|
||||
// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit
|
||||
// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a
|
||||
// check with ID document, liveness and face match costs $0.30)
|
||||
import { openDatabase } from "@t5de/db";
|
||||
import { AgeService, type Provider } from "./age";
|
||||
import { diditAgeProvider } from "./age/didit";
|
||||
import { imvuAgeProvider } from "./age/imvu";
|
||||
import { createApp } from "./app";
|
||||
import { createAuth } from "./auth";
|
||||
import { Settings } from "./settings";
|
||||
import { ImvuClient } from "./imvu";
|
||||
|
||||
const env = process.env;
|
||||
@@ -21,13 +31,56 @@ const apiURL = env.API_URL ?? `http://localhost:${port}`;
|
||||
const webURL = env.WEB_URL ?? "http://localhost:4100";
|
||||
|
||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
const settings = new Settings(db, { registration: env.REGISTRATION });
|
||||
const auth = createAuth(db, { apiURL, webURL, secret, settings });
|
||||
const imvu = new ImvuClient();
|
||||
|
||||
function required(name: string): string {
|
||||
const v = env[name];
|
||||
if (!v) {
|
||||
console.error(`${name} must be set`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const ageProviders: Record<string, () => Provider> = {
|
||||
imvu: () => imvuAgeProvider(imvu),
|
||||
didit: () =>
|
||||
diditAgeProvider({
|
||||
apiKey: required("DIDIT_API_KEY"),
|
||||
workflowId: required("DIDIT_WORKFLOW_ID"),
|
||||
webhookSecret: required("DIDIT_WEBHOOK_SECRET"),
|
||||
monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30),
|
||||
}),
|
||||
};
|
||||
const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean);
|
||||
if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer
|
||||
for (const id of providers) {
|
||||
if (!ageProviders[id]) {
|
||||
console.error(`AGE_PROVIDERS: no provider ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!()));
|
||||
|
||||
const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings });
|
||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||
|
||||
// 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; drafts never published go after 30
|
||||
// days.
|
||||
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
|
||||
const purgeDrafts = () =>
|
||||
products
|
||||
.purgeDrafts()
|
||||
.then((n) => n > 0 && console.log(`deleted ${n} drafts never published`))
|
||||
.catch((e) => console.error("drafts:", e));
|
||||
parentInfo();
|
||||
purgeDrafts();
|
||||
setInterval(() => {
|
||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||
parentInfo();
|
||||
purgeDrafts();
|
||||
}, 10 * 60 * 1000);
|
||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Notifications (docs/console.md): something for a person to know, shown on the website and sent to their proxies
|
||||
// over the realtime connection ({"type": "notification"}), so a client can show them later.
|
||||
import { and, count, desc, eq, inArray, isNull, lt, schema, type AuditTags, type Database } from "@t5de/db";
|
||||
|
||||
const { notification } = schema;
|
||||
|
||||
/**
|
||||
* The kinds of notification, and what their data holds:
|
||||
* - product_edited: someone other than its creator changed a product's info `{product, name, kind, fields, reason, by}`
|
||||
* - case_decided: a case they opened was decided `{case, kind, status, resolution}`
|
||||
* - case_note: someone replied on a case they opened or are assigned `{case, kind, by}`
|
||||
* - product_removed: moderation removed their product `{product, name, reason}`
|
||||
* - product_restored: moderation restored it `{product, name, reason}`
|
||||
* - membership_decided: a catalog decided their membership `{catalog, name, status}` (member, declined, banned)
|
||||
*/
|
||||
export type NotificationType = "product_edited" | "product_removed" | "product_restored" | "case_decided" | "case_note" | "membership_decided";
|
||||
|
||||
export class Notifications {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly push: (userId: string, message: object) => void = () => {},
|
||||
) {}
|
||||
|
||||
/** Tells a person something (never the one who did it: pass `actorId` to leave them out). */
|
||||
async add(userId: string | null | undefined, type: NotificationType, data: Record<string, unknown>, tags: AuditTags = {}, actorId?: string) {
|
||||
if (!userId || userId === actorId) return;
|
||||
await this.db.insert(notification).values({ userId, type, data, tags });
|
||||
this.push(userId, { type: "notification" });
|
||||
}
|
||||
|
||||
/** A person's notifications, newest first; `before` an ID for the next page. */
|
||||
async list(userId: string, before?: number, limit = 50) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), before ? lt(notification.id, before) : undefined))
|
||||
.orderBy(desc(notification.id))
|
||||
.limit(Math.max(1, Math.min(limit, 100)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async unread(userId: string): Promise<number> {
|
||||
const [row] = await this.db
|
||||
.select({ n: count() })
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), isNull(notification.readAt)));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
/** Marks some (or all) of a person's notifications read. */
|
||||
async markRead(userId: string, ids?: number[]) {
|
||||
await this.db
|
||||
.update(notification)
|
||||
.set({ readAt: new Date() })
|
||||
.where(
|
||||
and(eq(notification.userId, userId), isNull(notification.readAt), ids ? inArray(notification.id, ids) : undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of
|
||||
// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's
|
||||
// creator), and other roles are role_assignment rows.
|
||||
import { and, eq, schema, type Database, type TokenGrant } from "@t5de/db";
|
||||
|
||||
const { catalog, product, roleAssignment, user } = schema;
|
||||
|
||||
export const permissions = [
|
||||
"product:edit",
|
||||
"product:edit-locked",
|
||||
"product:lock",
|
||||
"product:rate-lower",
|
||||
"product:publish",
|
||||
"product:delete",
|
||||
"product:restore",
|
||||
"product:remove",
|
||||
"product:unremove",
|
||||
"product:transfer",
|
||||
"revision:upload",
|
||||
"revision:retract",
|
||||
"catalog:approve",
|
||||
"catalog:edit",
|
||||
"catalog:rotate-key",
|
||||
"catalog:suspend",
|
||||
"catalog:delete-request",
|
||||
"catalog:delete-review",
|
||||
"catalog:transfer",
|
||||
"team:manage",
|
||||
"member:approve",
|
||||
"member:invite",
|
||||
"member:remove",
|
||||
"member:ban",
|
||||
"hosting:choose",
|
||||
"hosting:delist",
|
||||
"import:grant",
|
||||
"import:grant-on-behalf",
|
||||
"import:accept",
|
||||
"import:run",
|
||||
"import:pause",
|
||||
"import:withdraw",
|
||||
"case:read",
|
||||
"case:resolve",
|
||||
"case:assign",
|
||||
"audit:read",
|
||||
"audit:read-sensitive",
|
||||
"stats:read",
|
||||
"person:approve",
|
||||
"person:ban",
|
||||
"person:link-cap",
|
||||
"person:notes",
|
||||
"person:role",
|
||||
"person:view-as",
|
||||
"room:edit",
|
||||
"room:close",
|
||||
"room:kick",
|
||||
"room:ban",
|
||||
"room:mute",
|
||||
"settings:edit",
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof permissions)[number];
|
||||
|
||||
/** Where a permission applies. The hub covers everything. */
|
||||
export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string };
|
||||
|
||||
export const hubScope: Scope = { type: "hub" };
|
||||
|
||||
const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":"));
|
||||
const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p));
|
||||
|
||||
const catalogOwner: Permission[] = [
|
||||
"catalog:edit",
|
||||
"catalog:rotate-key",
|
||||
"catalog:delete-request",
|
||||
"catalog:transfer",
|
||||
"team:manage",
|
||||
...all("member"),
|
||||
"hosting:delist",
|
||||
"import:grant",
|
||||
"import:accept",
|
||||
"import:run",
|
||||
"import:pause",
|
||||
"import:withdraw",
|
||||
"case:read",
|
||||
"case:resolve",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
];
|
||||
|
||||
/** The built-in roles: name, the scope they're given at, and their permissions. */
|
||||
export const roles = {
|
||||
admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] },
|
||||
moderator: {
|
||||
name: "Moderator",
|
||||
scope: "hub",
|
||||
permissions: [
|
||||
...without(all("product"), "product:transfer"),
|
||||
"revision:retract",
|
||||
"catalog:approve",
|
||||
"catalog:suspend",
|
||||
"catalog:delete-review",
|
||||
"hosting:delist",
|
||||
"import:grant-on-behalf",
|
||||
"import:withdraw",
|
||||
"import:pause",
|
||||
...all("case"),
|
||||
"person:approve",
|
||||
"person:ban",
|
||||
"person:link-cap",
|
||||
"person:notes",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
...all("room"),
|
||||
] as Permission[],
|
||||
},
|
||||
"catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner },
|
||||
"catalog-operator": {
|
||||
name: "Operator",
|
||||
scope: "catalog",
|
||||
permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"),
|
||||
},
|
||||
creator: {
|
||||
name: "Creator",
|
||||
scope: "product",
|
||||
permissions: [
|
||||
"product:edit",
|
||||
"product:publish",
|
||||
"product:delete",
|
||||
"product:restore",
|
||||
"product:transfer",
|
||||
"revision:upload",
|
||||
"revision:retract",
|
||||
"hosting:choose",
|
||||
"case:read",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
] as Permission[],
|
||||
},
|
||||
} as const satisfies Record<string, { name: string; scope: string; permissions: Permission[] }>;
|
||||
|
||||
export type RoleId = keyof typeof roles;
|
||||
|
||||
/** Roles that can be given at a catalog (the owner is derived, never given). */
|
||||
export const assignableCatalogRoles = ["catalog-operator"] as const;
|
||||
|
||||
/** A person, as the check needs them. Signed in with an API token, only what it grants too (the intersection). */
|
||||
export type Who = { id: string; role: string; token?: { id: string; name: string; grants: TokenGrant[] } };
|
||||
|
||||
/** Whether a token (if any) grants a permission at a scope: granted there, or hub-wide. */
|
||||
export function granted(who: Who | undefined, permission: Permission, scope: Scope): boolean {
|
||||
if (!who?.token) return true;
|
||||
return who.token.grants.some(
|
||||
(g) =>
|
||||
g.permission === permission &&
|
||||
(g.scope.type === "hub" || (scope.type !== "hub" && g.scope.type === scope.type && g.scope.id === scope.id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
|
||||
export class Forbidden extends Error {
|
||||
constructor(
|
||||
readonly permission: Permission,
|
||||
readonly scope: Scope,
|
||||
) {
|
||||
super("You don't have permission to do that.");
|
||||
}
|
||||
}
|
||||
|
||||
export class Permissions {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
/** A person's hub-wide permissions (their user.role). */
|
||||
hub(who: Who | undefined): Set<Permission> {
|
||||
const held = who?.role === "admin" ? roles.admin.permissions : who?.role === "moderator" ? roles.moderator.permissions : [];
|
||||
return new Set(held.filter((p) => granted(who, p, hubScope)));
|
||||
}
|
||||
|
||||
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
|
||||
async rolesAt(who: Who | undefined, scope: Scope): Promise<RoleId[]> {
|
||||
if (!who) return [];
|
||||
const out: RoleId[] = [];
|
||||
if (who.role === "admin") out.push("admin");
|
||||
else if (who.role === "moderator") out.push("moderator");
|
||||
if (scope.type === "catalog") {
|
||||
const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id));
|
||||
if (c?.ownerId === who.id) out.push("catalog-owner");
|
||||
const given = await this.db
|
||||
.select({ role: roleAssignment.role })
|
||||
.from(roleAssignment)
|
||||
.where(
|
||||
and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)),
|
||||
);
|
||||
for (const g of given) if (g.role in roles) out.push(g.role as RoleId);
|
||||
} else if (scope.type === "product") {
|
||||
const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id));
|
||||
if (p?.creatorId === who.id) out.push("creator");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every permission a person holds at a scope. */
|
||||
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
|
||||
const out = new Set<Permission>();
|
||||
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) if (granted(who, p, scope)) out.add(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
async can(who: Who | undefined, permission: Permission, scope: Scope): Promise<boolean> {
|
||||
if (this.hub(who).has(permission)) return true;
|
||||
if (scope.type === "hub") return false;
|
||||
return (await this.at(who, scope)).has(permission);
|
||||
}
|
||||
|
||||
/** Throws Forbidden unless the person holds the permission at the scope. */
|
||||
async require(who: Who | undefined, permission: Permission, scope: Scope): Promise<void> {
|
||||
if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope);
|
||||
}
|
||||
|
||||
/** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */
|
||||
async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise<boolean> {
|
||||
const held = await this.at(who, scope);
|
||||
return roles[role].permissions.every((p) => held.has(p));
|
||||
}
|
||||
|
||||
/** The catalogs a person is on the team of (owner or a role there). */
|
||||
async teamCatalogs(personId: string): Promise<string[]> {
|
||||
const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId));
|
||||
const given = await this.db
|
||||
.select({ id: roleAssignment.scopeId })
|
||||
.from(roleAssignment)
|
||||
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog")));
|
||||
return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])];
|
||||
}
|
||||
|
||||
/** The products a person created. */
|
||||
async ownProducts(personId: string): Promise<string[]> {
|
||||
return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id);
|
||||
}
|
||||
|
||||
/** A catalog's team: the owner and everyone given a role there, with their names. */
|
||||
async team(catalogId: string) {
|
||||
const [c] = await this.db
|
||||
.select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username })
|
||||
.from(catalog)
|
||||
.innerJoin(user, eq(user.id, catalog.ownerId))
|
||||
.where(eq(catalog.id, catalogId));
|
||||
const given = await this.db
|
||||
.select({
|
||||
personId: roleAssignment.userId,
|
||||
role: roleAssignment.role,
|
||||
name: user.displayUsername,
|
||||
username: user.username,
|
||||
since: roleAssignment.createdAt,
|
||||
})
|
||||
.from(roleAssignment)
|
||||
.innerJoin(user, eq(user.id, roleAssignment.userId))
|
||||
.where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId)));
|
||||
return {
|
||||
owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined,
|
||||
roles: given.map((g) => ({
|
||||
personId: g.personId,
|
||||
role: g.role,
|
||||
username: g.name ?? g.username ?? "",
|
||||
since: g.since.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
+565
-31
@@ -1,15 +1,57 @@
|
||||
// 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;
|
||||
const { catalog, catalogChange, catalogMember, fileBlock, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } =
|
||||
schema;
|
||||
|
||||
export type Catalog = typeof catalog.$inferSelect;
|
||||
export type Product = typeof product.$inferSelect;
|
||||
export type Revision = typeof productRevision.$inferSelect;
|
||||
|
||||
/** Info fields people edit (and moderators lock). */
|
||||
export const infoFields = ["name", "description", "rating", "tags", "listed", "category"] as const;
|
||||
export type InfoField = (typeof infoFields)[number];
|
||||
/** `category`: an IMVU category ID from categoryChoices, or null for its parent's. */
|
||||
export type InfoInput = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
rating?: string;
|
||||
tags?: string[];
|
||||
listed?: boolean;
|
||||
category?: number | null;
|
||||
};
|
||||
|
||||
/** An IMVU category path, outermost first. */
|
||||
export type CategoryPath = { id: number; name: string }[];
|
||||
|
||||
/** What the person editing may do beyond product:edit (from the permission check). */
|
||||
export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean };
|
||||
|
||||
/** Who's looking: drafts are only for their creator and moderators. */
|
||||
export type ProductViewer = { id: string; moderator: boolean };
|
||||
|
||||
/** Drafts never published are deleted after this long. */
|
||||
export const draftDays = 30;
|
||||
|
||||
/** An error for the person; the routes turn it into a Problem. */
|
||||
export class ProductError extends Error {
|
||||
constructor(
|
||||
@@ -21,6 +63,17 @@ export class ProductError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** An upload with a blocked file (docs/products.md): refused, and the route audits who tried. */
|
||||
export class BlockedUpload extends ProductError {
|
||||
constructor(
|
||||
readonly personId: string,
|
||||
readonly catalogId: string,
|
||||
readonly files: FileEntry[],
|
||||
) {
|
||||
super(409, "blocked", "This upload has a file T5DE blocks.");
|
||||
}
|
||||
}
|
||||
|
||||
const base32 = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
export const newProductID = () =>
|
||||
[...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join("");
|
||||
@@ -34,6 +87,19 @@ const nameRule = (s: unknown, max: number) => {
|
||||
return v;
|
||||
};
|
||||
|
||||
/** Tags as people type them: lowercase words (letters, digits, spaces, dashes), at most 10, each up to 24. */
|
||||
export function checkTags(tags: unknown): string[] | undefined {
|
||||
if (!Array.isArray(tags) || tags.length > 10) return undefined;
|
||||
const out: string[] = [];
|
||||
for (const t of tags) {
|
||||
const v = typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "";
|
||||
if (!v) continue;
|
||||
if (v.length > 24 || !/^[\p{L}\p{N}][\p{L}\p{N} -]*$/u.test(v)) return undefined;
|
||||
if (!out.includes(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A file list as catalogs send it: checked and sorted by name. */
|
||||
export function checkFiles(files: unknown): FileEntry[] | undefined {
|
||||
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
|
||||
@@ -55,10 +121,32 @@ 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;
|
||||
|
||||
/**
|
||||
* Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
|
||||
* catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team.
|
||||
*/
|
||||
export const availableTo = (personId: string) => sql`(${product.status} = 'published' and (
|
||||
${product.creatorId} = ${personId} or exists (
|
||||
select 1 from ${hosting} h
|
||||
join ${catalog} c on c.id = h.catalog_id
|
||||
left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId}
|
||||
where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned'
|
||||
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
|
||||
or exists (select 1 from ${roleAssignment} r
|
||||
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
|
||||
)))`;
|
||||
|
||||
/** 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 ----
|
||||
@@ -100,12 +188,13 @@ export class Products {
|
||||
return { catalog: c!, key };
|
||||
}
|
||||
|
||||
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */
|
||||
async catalogFor(header: string | null): Promise<Catalog | undefined> {
|
||||
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved (or suspended, when asked)
|
||||
* and the key is right. */
|
||||
async catalogFor(header: string | null, suspendedToo = false): Promise<Catalog | undefined> {
|
||||
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
|
||||
if (!m) return undefined;
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
|
||||
if (!c || c.status !== "approved") return undefined;
|
||||
if (!c || !(c.status === "approved" || (suspendedToo && c.status === "suspended"))) return undefined;
|
||||
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
|
||||
}
|
||||
|
||||
@@ -140,6 +229,23 @@ export class Products {
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
|
||||
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
|
||||
if (productId) await this.own(personId, productId);
|
||||
if (c.uploads !== "anyone" && c.ownerId !== personId) {
|
||||
const [team] = await this.db
|
||||
.select({ role: roleAssignment.role })
|
||||
.from(roleAssignment)
|
||||
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, c.id)));
|
||||
const [m] = await this.db
|
||||
.select({ status: catalogMember.status })
|
||||
.from(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, c.id), eq(catalogMember.userId, personId)));
|
||||
const ok = team !== undefined || (c.uploads === "members" && m?.status === "member");
|
||||
if (!ok)
|
||||
throw new ProductError(
|
||||
403,
|
||||
"forbidden",
|
||||
c.uploads === "members" ? "Only this catalog's members can upload to it." : "Only this catalog's team can upload to it.",
|
||||
);
|
||||
}
|
||||
const token = await this.signer.sign(
|
||||
"t5de-upload",
|
||||
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
|
||||
@@ -160,11 +266,22 @@ export class Products {
|
||||
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
|
||||
if (body.rating !== "general" && body.rating !== "adult")
|
||||
throw new ProductError(400, "invalid", "The rating must be general or adult.");
|
||||
if (body.rating === "adult" && c.maxRating === "general")
|
||||
throw new ProductError(400, "rating_ceiling", "This catalog hosts general-audience products only.");
|
||||
const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
|
||||
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
||||
const personId = claims.sub;
|
||||
const blocked = new Set(
|
||||
(
|
||||
await this.db
|
||||
.select({ sha256: fileBlock.sha256 })
|
||||
.from(fileBlock)
|
||||
.where(inArray(fileBlock.sha256, [...new Set(files.map((f) => f.sha256))]))
|
||||
).map((b) => b.sha256),
|
||||
);
|
||||
if (blocked.size) throw new BlockedUpload(personId, c.id, files.filter((f) => blocked.has(f.sha256)));
|
||||
|
||||
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");
|
||||
@@ -181,7 +298,7 @@ export class Products {
|
||||
const name = nameRule(body.name, 64) ?? "Untitled";
|
||||
const [made] = await tx
|
||||
.insert(product)
|
||||
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
|
||||
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
|
||||
.returning();
|
||||
p = made!;
|
||||
}
|
||||
@@ -207,8 +324,64 @@ export class Products {
|
||||
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
|
||||
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
|
||||
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
|
||||
return { product: p.id, revision: number };
|
||||
return { product: p.id, revision: number, draft: p.status === "draft" };
|
||||
});
|
||||
// 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,32 +390,111 @@ 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;
|
||||
}
|
||||
|
||||
/** Undoes a moderation removal (an upheld appeal, a mistake): its page, files and library entries are back, since
|
||||
* removing only hides them. */
|
||||
async unremove(id: string) {
|
||||
const [p] = await this.db
|
||||
.update(product)
|
||||
.set({ removedAt: null, removedReason: null })
|
||||
.where(and(eq(product.id, id), sql`${product.removedAt} is not null`))
|
||||
.returning();
|
||||
if (!p) throw new ProductError(404, "not_found", "No such removed product.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
|
||||
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
|
||||
* the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info.
|
||||
*/
|
||||
async list(opts: {
|
||||
q?: string;
|
||||
creator?: string;
|
||||
category?: number;
|
||||
tag?: string;
|
||||
catalog?: string;
|
||||
adult: boolean;
|
||||
viewerId?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const conds = [
|
||||
ne(product.latest, 0),
|
||||
eq(product.status, "published"),
|
||||
isNull(product.deletedAt),
|
||||
isNull(product.removedAt),
|
||||
opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true),
|
||||
];
|
||||
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`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`,
|
||||
);
|
||||
const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined;
|
||||
if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`);
|
||||
if (opts.catalog)
|
||||
conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`);
|
||||
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);
|
||||
}
|
||||
|
||||
async get(id: string, adult: boolean) {
|
||||
/** 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, viewer?: ProductViewer) {
|
||||
const [row] = await this.db
|
||||
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
|
||||
.from(product)
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(product.id, id));
|
||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
// Its creator and moderators always see it: drafts, and adult products without an adult account.
|
||||
const responsible = viewer?.moderator || viewer?.id === row.product.creatorId;
|
||||
if (row.product.status === "draft" && !responsible) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.rating === "adult" && !adult && !responsible) throw new ProductError(404, "not_found", "No such product.");
|
||||
const revisions = await this.db
|
||||
.select()
|
||||
.from(productRevision)
|
||||
@@ -256,27 +508,276 @@ export class Products {
|
||||
return { ...row, revisions, hosts };
|
||||
}
|
||||
|
||||
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
|
||||
const p = await this.own(personId, id);
|
||||
const set: Partial<Product> = {};
|
||||
// ---- product info: edits, history, locks, publishing ----
|
||||
|
||||
/** A product for changing its info: not removed; deleted ones only by others than the creator (moderators). */
|
||||
private async editable(id: string, rights: EditRights) {
|
||||
const [p] = await this.db.select().from(product).where(eq(product.id, id));
|
||||
if (!p || p.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (p.deletedAt && rights.creator) throw new ProductError(410, "deleted", "You deleted this product.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a product's info (the route checked product:edit). Only fields that change are kept in its history.
|
||||
* Someone other than the creator must give a reason; locked fields need editLocked; lowering the rating needs
|
||||
* rateLower (and files rated general). Returns the product, and the history entry if anything changed.
|
||||
*/
|
||||
async updateInfo(
|
||||
byId: string,
|
||||
id: string,
|
||||
input: InfoInput,
|
||||
reason: string,
|
||||
rights: EditRights,
|
||||
revertOf?: number,
|
||||
): Promise<{ product: Product; entry?: number; fields: InfoField[] }> {
|
||||
const p = await this.editable(id, rights);
|
||||
reason = reason.trim().slice(0, 500);
|
||||
if (!rights.creator && !reason)
|
||||
throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason with the change.");
|
||||
const next: Partial<Pick<Product, InfoField>> = {};
|
||||
if (input.name !== undefined) {
|
||||
const name = nameRule(input.name, 64);
|
||||
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
|
||||
set.name = name;
|
||||
next.name = name;
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
if (input.description.length > maxDescription)
|
||||
throw new ProductError(400, "invalid", "That description is too long.");
|
||||
set.description = cleanHTML(input.description);
|
||||
next.description = cleanHTML(input.description);
|
||||
}
|
||||
if (input.rating !== undefined) {
|
||||
if (input.rating === "adult") set.rating = "adult";
|
||||
else if (input.rating === "general" && p.rating === "adult")
|
||||
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
|
||||
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||
if (input.rating !== "general" && input.rating !== "adult")
|
||||
throw new ProductError(400, "invalid", "The rating must be general or adult.");
|
||||
next.rating = input.rating;
|
||||
}
|
||||
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
||||
if (input.tags !== undefined) {
|
||||
const tags = checkTags(input.tags);
|
||||
if (!tags)
|
||||
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
|
||||
next.tags = tags;
|
||||
}
|
||||
if (input.listed !== undefined) next.listed = input.listed;
|
||||
if (input.category !== undefined) {
|
||||
if (input.category === null) next.category = null;
|
||||
else {
|
||||
const path = (await this.categoryChoices(p)).find((c) => c.at(-1)!.id === input.category);
|
||||
if (!path)
|
||||
throw new ProductError(400, "invalid", "Choose one of the categories offered for this product.");
|
||||
next.category = path;
|
||||
}
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const f of infoFields)
|
||||
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
|
||||
const fields = Object.keys(changes) as InfoField[];
|
||||
if (fields.length === 0) return { product: p, fields };
|
||||
|
||||
const locked = fields.filter((f) => p.locked.includes(f));
|
||||
if (locked.length && !rights.editLocked)
|
||||
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
|
||||
if (changes.rating?.to === "general") {
|
||||
if (!rights.rateLower)
|
||||
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||
await this.checkFilesGeneral(id);
|
||||
}
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
|
||||
const [entry] = await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
|
||||
.returning({ id: productInfoChange.id });
|
||||
return { product: updated!, entry: entry!.id, fields };
|
||||
});
|
||||
}
|
||||
|
||||
/** Refuses (400 files_adult) if a revision of the product that isn't retracted is rated adult. */
|
||||
async checkFilesGeneral(id: string) {
|
||||
const [adultFiles] = await this.db
|
||||
.select({ n: productRevision.number })
|
||||
.from(productRevision)
|
||||
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
|
||||
.limit(1);
|
||||
if (adultFiles)
|
||||
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
|
||||
}
|
||||
|
||||
/** A draft becomes published: listed (unless unlisted), and people can get it. */
|
||||
async publish(byId: string, id: string) {
|
||||
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
|
||||
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
|
||||
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(product)
|
||||
.set({ status: "published", publishedAt: new Date() })
|
||||
.where(eq(product.id, id))
|
||||
.returning();
|
||||
await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
|
||||
async setLocks(byId: string, id: string, fields: string[], reason: string) {
|
||||
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
|
||||
const want = infoFields.filter((f) => fields.includes(f));
|
||||
if (want.length !== new Set(fields).size)
|
||||
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
|
||||
reason = reason.trim().slice(0, 500);
|
||||
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
|
||||
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
|
||||
await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories a product may be filed under (docs/products.md): every IMVU category path the hub has seen on a
|
||||
* revision's parent, and each step of those paths, under the same top-level category as its own parent's (any, if
|
||||
* its parent's is unknown). Keeping the top level keeps it the same kind of product (clothing, furniture, rooms).
|
||||
*/
|
||||
async categoryChoices(p: Product): Promise<CategoryPath[]> {
|
||||
const parent = (await this.latestParent(p))?.parentInfo?.categories ?? [];
|
||||
const seen = await this.db
|
||||
.selectDistinct({ path: sql<CategoryPath>`${productRevision.parentInfo}->'categories'` })
|
||||
.from(productRevision)
|
||||
.where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`);
|
||||
const byId = new Map<number, CategoryPath>();
|
||||
for (const path of [parent, ...seen.map((s) => s.path)])
|
||||
for (let i = 0; i < path.length; i++)
|
||||
if (!byId.has(path[i]!.id)) byId.set(path[i]!.id, path.slice(0, i + 1).map((c) => ({ id: c.id, name: c.name })));
|
||||
const top = parent[0]?.id;
|
||||
const label = (c: CategoryPath) => c.map((x) => x.name).join(" / ");
|
||||
return [...byId.values()].filter((c) => top === undefined || c[0]!.id === top).sort((a, b) => label(a).localeCompare(label(b)));
|
||||
}
|
||||
|
||||
/** A product's info history, newest first, with who made each change. */
|
||||
async history(id: string) {
|
||||
return this.db
|
||||
.select({
|
||||
id: productInfoChange.id,
|
||||
at: productInfoChange.createdAt,
|
||||
kind: productInfoChange.kind,
|
||||
changes: productInfoChange.changes,
|
||||
reason: productInfoChange.reason,
|
||||
revertOf: productInfoChange.revertOf,
|
||||
byId: productInfoChange.byId,
|
||||
by: user.displayUsername,
|
||||
})
|
||||
.from(productInfoChange)
|
||||
.leftJoin(user, eq(user.id, productInfoChange.byId))
|
||||
.where(eq(productInfoChange.productId, id))
|
||||
.orderBy(desc(productInfoChange.id));
|
||||
}
|
||||
|
||||
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
|
||||
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
|
||||
const [e] = await this.db
|
||||
.select()
|
||||
.from(productInfoChange)
|
||||
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
|
||||
if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from;
|
||||
// Categories are kept as paths; an edit takes the category's ID.
|
||||
if (Array.isArray(input.category)) input.category = (input.category as CategoryPath).at(-1)?.id ?? null;
|
||||
const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id);
|
||||
if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
|
||||
async purgeDrafts(now = new Date()): Promise<number> {
|
||||
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
|
||||
const stale = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
|
||||
for (const { id } of stale) {
|
||||
await this.db.transaction(async (tx) => {
|
||||
const hosted = await tx
|
||||
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
|
||||
.from(hosting)
|
||||
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
|
||||
.where(eq(hosting.productId, id));
|
||||
for (const h of hosted)
|
||||
await tx
|
||||
.insert(catalogChange)
|
||||
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
|
||||
await tx.delete(hosting).where(eq(hosting.productId, id));
|
||||
await tx.delete(product).where(eq(product.id, id));
|
||||
});
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
|
||||
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
|
||||
*/
|
||||
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0)];
|
||||
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
|
||||
if (opts.q) {
|
||||
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
|
||||
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
|
||||
}
|
||||
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
|
||||
switch (opts.status) {
|
||||
case "draft":
|
||||
conds.push(eq(product.status, "draft"), live);
|
||||
break;
|
||||
case "published":
|
||||
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
|
||||
break;
|
||||
case "unlisted":
|
||||
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
|
||||
break;
|
||||
case "deleted":
|
||||
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
|
||||
break;
|
||||
case "removed":
|
||||
conds.push(sql`${product.removedAt} is not null`);
|
||||
break;
|
||||
case "locked":
|
||||
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
|
||||
break;
|
||||
}
|
||||
return this.db
|
||||
.select({
|
||||
product,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
|
||||
})
|
||||
.from(product)
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(product.updatedAt))
|
||||
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
|
||||
}
|
||||
|
||||
/** How many drafts a person has (for the console's to-dos). */
|
||||
async draftCount(creatorId: string) {
|
||||
const [row] = await this.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(product)
|
||||
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async retract(personId: string, id: string, number: number) {
|
||||
@@ -294,11 +795,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), availableTo(personId)))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -310,11 +819,35 @@ export class Products {
|
||||
return row;
|
||||
}
|
||||
|
||||
async add(personId: string, productId: string, adult: boolean) {
|
||||
await this.get(productId, adult);
|
||||
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const p = await this.get(productId, adult, viewer);
|
||||
// Seeing an adult product is open to its creator and moderators; getting it isn't.
|
||||
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
|
||||
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
|
||||
throw new ProductError(410, "deleted", "Its creator deleted this product.");
|
||||
if (!(await this.available(personId, productId)))
|
||||
throw new ProductError(403, "members_only", "Join a catalog hosting this product to get it.");
|
||||
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async available(personId: string, productId: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(eq(product.id, productId), availableTo(personId)));
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
/** The approved catalogs hosting a product, with their membership rule (for "join to get it"). */
|
||||
async hostingCatalogs(productId: string) {
|
||||
return this.db
|
||||
.selectDistinct({ id: catalog.id, name: catalog.name, membership: catalog.membership, visibility: catalog.visibility })
|
||||
.from(hosting)
|
||||
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
|
||||
.where(and(eq(hosting.productId, productId), eq(catalog.status, "approved")));
|
||||
}
|
||||
|
||||
async remove(personId: string, productId: string) {
|
||||
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
|
||||
}
|
||||
@@ -339,8 +872,9 @@ export class Products {
|
||||
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
|
||||
* the nearest earlier one that isn't (or later, if none).
|
||||
*/
|
||||
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
|
||||
const p = await this.get(productId, adult);
|
||||
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const p = await this.get(productId, adult, viewer);
|
||||
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
|
||||
const want = pin ?? p.product.latest;
|
||||
const usable = p.revisions.filter((r) => !r.retracted);
|
||||
|
||||
+490
-5
@@ -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,84 @@ 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>;
|
||||
/**
|
||||
* Of these hub product IDs, the ones a person may wear (available to them: docs/products.md, "Member"). Outfit
|
||||
* items with other hub IDs aren't relayed; items that aren't hub IDs (local packs) are.
|
||||
*/
|
||||
wearable?: (userId: string, productIds: string[]) => Promise<Set<string>>;
|
||||
};
|
||||
|
||||
const hubProductID = /^[a-z2-7]{10}$/;
|
||||
|
||||
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 () => "",
|
||||
wearable: async (_, ids) => new Set(ids),
|
||||
...d,
|
||||
};
|
||||
}
|
||||
|
||||
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||
@@ -94,6 +229,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 +243,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 +271,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 +289,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 +324,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 +616,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;
|
||||
@@ -206,10 +652,16 @@ export class Realtime {
|
||||
const items = m.items ?? [];
|
||||
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
|
||||
if (items.length > 64) return { type: "error", message: "too many items" };
|
||||
s.outfit = items
|
||||
let outfit = items
|
||||
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
||||
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
||||
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
||||
const hubIds = outfit.filter((it) => hubProductID.test(it.id)).map((it) => it.id);
|
||||
if (hubIds.length > 0) {
|
||||
const ok = await this.deps.wearable(s.userId, hubIds);
|
||||
outfit = outfit.filter((it) => !hubProductID.test(it.id) || ok.has(it.id));
|
||||
}
|
||||
s.outfit = outfit;
|
||||
this.changed(s.chats.keys());
|
||||
return;
|
||||
}
|
||||
@@ -231,6 +683,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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its
|
||||
// default from the environment (so a new staging hub can start with registration closed).
|
||||
import { eq, schema, type Database } from "@t5de/db";
|
||||
|
||||
const { hubSetting } = schema;
|
||||
|
||||
/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */
|
||||
export type Registration = "open" | "closed";
|
||||
export const registrations: readonly Registration[] = ["open", "closed"];
|
||||
|
||||
export class Settings {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly defaults: { registration?: string } = {},
|
||||
) {}
|
||||
|
||||
async registration(): Promise<Registration> {
|
||||
const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration"));
|
||||
const value = row?.value ?? this.defaults.registration;
|
||||
return value === "closed" ? "closed" : "open";
|
||||
}
|
||||
|
||||
async setRegistration(value: Registration) {
|
||||
await this.db
|
||||
.insert(hubSetting)
|
||||
.values({ key: "registration", value })
|
||||
.onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Personal API tokens (docs/console.md): for scripts. Each grants some of its owner's permissions at chosen scopes;
|
||||
// what it may do is always the intersection with what the owner holds now. Only the hash is kept, and the token is
|
||||
// shown once. Making, listing and revoking tokens takes a website session (a token can't make tokens).
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gt, isNull, schema, sql, type Database, type TokenGrant } from "@t5de/db";
|
||||
import { ProductError } from "./products";
|
||||
import { permissions, type Permission, type Permissions, type Scope } from "./permissions";
|
||||
|
||||
const { apiToken } = schema;
|
||||
|
||||
export const tokenPrefix = "t5de_pat_";
|
||||
/** Active tokens a person may have at once. */
|
||||
export const maxTokens = 20;
|
||||
/** Grants a token may carry. */
|
||||
export const maxGrants = 50;
|
||||
/** The longest a token may last. */
|
||||
export const maxTokenDays = 365;
|
||||
|
||||
const hashOf = (raw: string) => createHash("sha256").update(raw).digest("hex");
|
||||
|
||||
export type ApiToken = typeof apiToken.$inferSelect;
|
||||
|
||||
export class Tokens {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private perms: Permissions,
|
||||
) {}
|
||||
|
||||
/** Makes a token for a person; every grant must be something they hold now. Returns the token (shown once). */
|
||||
async create(who: { id: string; role: string }, name: string, days: number, grants: TokenGrant[]) {
|
||||
const label = name.trim().replace(/\s+/g, " ");
|
||||
if (!label || label.length > 64) throw new ProductError(400, "invalid", "Name it (at most 64 characters).");
|
||||
if (!Number.isInteger(days) || days < 1 || days > maxTokenDays)
|
||||
throw new ProductError(400, "invalid", `It may last 1 to ${maxTokenDays} days.`);
|
||||
if (grants.length === 0 || grants.length > maxGrants)
|
||||
throw new ProductError(400, "invalid", `Give it 1 to ${maxGrants} permissions.`);
|
||||
const seen = new Set<string>();
|
||||
const clean: TokenGrant[] = [];
|
||||
for (const g of grants) {
|
||||
if (!(permissions as readonly string[]).includes(g.permission))
|
||||
throw new ProductError(400, "invalid", `No such permission: ${g.permission}.`);
|
||||
const scope: Scope = g.scope.type === "hub" ? { type: "hub" } : { type: g.scope.type, id: g.scope.id };
|
||||
if (!(await this.perms.can(who, g.permission as Permission, scope)))
|
||||
throw new ProductError(400, "not_held", `You don't hold ${g.permission} there, so a token can't either.`);
|
||||
const key = JSON.stringify([g.permission, scope]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
clean.push({ permission: g.permission, scope });
|
||||
}
|
||||
const [{ n } = { n: 0 }] = await this.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(apiToken)
|
||||
.where(and(eq(apiToken.userId, who.id), isNull(apiToken.revokedAt), gt(apiToken.expiresAt, new Date())));
|
||||
if (n >= maxTokens) throw new ProductError(409, "too_many", `You have ${maxTokens} tokens. Revoke one first.`);
|
||||
const raw = tokenPrefix + randomBytes(32).toString("base64url");
|
||||
const [made] = await this.db
|
||||
.insert(apiToken)
|
||||
.values({
|
||||
id: randomBytes(8).toString("hex"),
|
||||
userId: who.id,
|
||||
name: label,
|
||||
hash: hashOf(raw),
|
||||
grants: clean,
|
||||
expiresAt: new Date(Date.now() + days * 86_400_000),
|
||||
})
|
||||
.returning();
|
||||
return { token: raw, row: made! };
|
||||
}
|
||||
|
||||
/** A person's tokens, newest first (revoked and expired ones included, for the record). */
|
||||
async list(personId: string) {
|
||||
return this.db.select().from(apiToken).where(eq(apiToken.userId, personId)).orderBy(desc(apiToken.createdAt));
|
||||
}
|
||||
|
||||
async revoke(personId: string, id: string) {
|
||||
const [t] = await this.db
|
||||
.update(apiToken)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiToken.id, id), eq(apiToken.userId, personId), isNull(apiToken.revokedAt)))
|
||||
.returning();
|
||||
if (!t) throw new ProductError(404, "not_found", "No such token, or it's revoked already.");
|
||||
return t;
|
||||
}
|
||||
|
||||
/** The token a raw value names, if it's still good; notes its use (at most once a minute). */
|
||||
async verify(raw: string): Promise<ApiToken | undefined> {
|
||||
if (!raw.startsWith(tokenPrefix) || raw.length > 100) return undefined;
|
||||
const [t] = await this.db.select().from(apiToken).where(eq(apiToken.hash, hashOf(raw)));
|
||||
if (!t || t.revokedAt || t.expiresAt <= new Date()) return undefined;
|
||||
if (!t.lastUsedAt || Date.now() - t.lastUsedAt.getTime() > 60_000)
|
||||
await this.db.update(apiToken).set({ lastUsedAt: new Date() }).where(eq(apiToken.id, t.id));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
+147
-8
@@ -1,8 +1,10 @@
|
||||
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed
|
||||
// webhooks), and IMVU's answers changing.
|
||||
// Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
|
||||
// and Didit's adapter against a fake Didit), limits on checks, and IMVU's answers changing.
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { AgeService, type Provider } from "../src/age";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
|
||||
import { ageOn, diditAgeProvider } from "../src/age/didit";
|
||||
import { imvuAgeProvider } from "../src/age/imvu";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
@@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret";
|
||||
const fakeVendor: Provider = {
|
||||
id: "vendor",
|
||||
label: "ID document",
|
||||
vendor: "Vendor",
|
||||
scope: "person",
|
||||
start: async () => {
|
||||
const ref = `session-${crypto.randomUUID()}`;
|
||||
@@ -39,6 +42,45 @@ const fakeVendor: Provider = {
|
||||
},
|
||||
};
|
||||
|
||||
// Didit: sessions per person (an unfinished one is given again), and each session's decision.
|
||||
const diditSecret = "didit-secret";
|
||||
const diditSessions = new Map<string, { id: string; vendor: string; finished: boolean; decision?: unknown }>();
|
||||
const fakeDidit: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (new Headers(init?.headers).get("x-api-key") !== "didit-key") return new Response("no key", { status: 401 });
|
||||
if (init?.method === "POST" && url.pathname === "/v3/session/") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body.workflow_id).toBe("workflow");
|
||||
let s = [...diditSessions.values()].find((s) => s.vendor === body.vendor_data && !s.finished);
|
||||
if (!s) {
|
||||
s = { id: crypto.randomUUID(), vendor: body.vendor_data, finished: false };
|
||||
diditSessions.set(s.id, s);
|
||||
}
|
||||
return Response.json({ session_id: s.id, url: `https://verify.didit.test/session/${s.id}`, status: "Not Started" }, { status: 201 });
|
||||
}
|
||||
const m = /^\/v3\/session\/([^/]+)\/decision\/$/.exec(url.pathname);
|
||||
if (m && diditSessions.get(m[1]!)) return Response.json(diditSessions.get(m[1]!)!.decision ?? {});
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const didit = diditAgeProvider({ apiKey: "didit-key", workflowId: "workflow", webhookSecret: diditSecret, baseURL: "https://didit.test", fetch: fakeDidit });
|
||||
|
||||
/** A Didit webhook request, signed as Didit signs it (the raw body, or V2), or forged. */
|
||||
function diditHook(body: Record<string, unknown>, sign: "raw" | "v2" | "forged" = "raw", timestamp = Math.floor(Date.now() / 1000)) {
|
||||
const raw = JSON.stringify(body);
|
||||
const hmac = (s: string, key = diditSecret) => createHmac("sha256", key).update(s).digest("hex");
|
||||
const sortKeys = (v: unknown): unknown =>
|
||||
Array.isArray(v)
|
||||
? v.map(sortKeys)
|
||||
: v && typeof v === "object"
|
||||
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
|
||||
: v;
|
||||
const headers: Record<string, string> = { "X-Timestamp": String(timestamp), "Content-Type": "application/json" };
|
||||
if (sign === "raw") headers["X-Signature"] = hmac(raw);
|
||||
if (sign === "v2") headers["X-Signature-V2"] = hmac(JSON.stringify(sortKeys(body)));
|
||||
if (sign === "forged") headers["X-Signature"] = hmac(raw, "wrong");
|
||||
return fetch(`${base}/api/v1/age/webhook/didit`, { method: "POST", headers, body: raw });
|
||||
}
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
@@ -49,8 +91,8 @@ beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const imvu = new ImvuClient("http://imvu.test", fakeIMVU);
|
||||
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]);
|
||||
const hub = createApp({ db, auth, imvu, age });
|
||||
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
|
||||
const hub = createApp({ db, auth, imvu, age, webURL });
|
||||
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();
|
||||
@@ -78,7 +120,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);
|
||||
@@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const userOf = async (name: string) => (await db.select().from(schema.user)).find((u) => u.username === name)!.id;
|
||||
const startedThisMonth = async () => {
|
||||
const now = new Date();
|
||||
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
return (await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.provider, "didit"))).filter((r) => r.createdAt >= month).length;
|
||||
};
|
||||
|
||||
const adultOf = async (cookie: { v: string }) =>
|
||||
Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult]));
|
||||
|
||||
@@ -102,7 +151,7 @@ test("IMVU's answer is per account, and re-checked when it expires", async () =>
|
||||
|
||||
test("a person's check covers all their accounts; webhooks must be signed", async () => {
|
||||
const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]);
|
||||
expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor"]);
|
||||
expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor", "didit"]);
|
||||
// IMVU checks accounts, not people: it can't be started this way.
|
||||
expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400);
|
||||
|
||||
@@ -118,6 +167,96 @@ test("a person's check covers all their accounts; webhooks must be signed", asyn
|
||||
expect(await adultOf(b)).toEqual({ 10: true, 11: true });
|
||||
// A repeated webhook is harmless.
|
||||
expect((await hook(webhookSecret, true)).status).toBe(204);
|
||||
// Verified already: no more checks, with this provider or another.
|
||||
const again = await call(b, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
|
||||
expect(again).toMatchObject({ status: 409, json: { error: "refused" } });
|
||||
});
|
||||
|
||||
test("people are sent back only to the hub's website", async () => {
|
||||
const e = await personWithAccounts("eve", []);
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "https://evil.test/account" })).status).toBe(400);
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "//evil.test/account" })).status).toBe(400);
|
||||
// A path is on the website.
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "/account?age=back" })).json.kind).toBe("redirect");
|
||||
});
|
||||
|
||||
test("Didit: signed webhooks, the age on the document, and answers changed later", async () => {
|
||||
const f = await personWithAccounts("fay", [{ id: 30 }]);
|
||||
const started = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account?age=didit` })).json;
|
||||
expect(started).toMatchObject({ kind: "redirect", url: `https://verify.didit.test/session/${started.ref}` });
|
||||
// Going back to an unfinished check gives the same session, and doesn't count as another.
|
||||
const resumed = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
expect(resumed.ref).toBe(started.ref);
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks).toHaveLength(1);
|
||||
|
||||
const event = (status: string, decision?: unknown) => ({
|
||||
event_id: crypto.randomUUID(),
|
||||
webhook_type: "status.updated",
|
||||
session_id: started.ref,
|
||||
status,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
...(decision ? { decision } : {}),
|
||||
});
|
||||
const approved = event("Approved", { id_verifications: [{ status: "Approved", age: 31, date_of_birth: "1995-01-01" }] });
|
||||
expect((await diditHook(approved, "forged")).status).toBe(401);
|
||||
expect((await diditHook(approved, "raw", Math.floor(Date.now() / 1000) - 3600)).status).toBe(401); // stale
|
||||
// In review: no answer yet.
|
||||
expect((await diditHook(event("In Review"))).status).toBe(204);
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("pending");
|
||||
expect(await adultOf(f)).toEqual({ 30: false });
|
||||
|
||||
expect((await diditHook(approved, "v2")).status).toBe(204);
|
||||
expect(await adultOf(f)).toEqual({ 30: true });
|
||||
const [row] = await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.providerRef, started.ref));
|
||||
expect(row).toMatchObject({ provider: "didit", method: "id_document", status: "complete", overEighteen: true, imvuId: null });
|
||||
|
||||
// A reviewer at Didit declines it afterwards: the answer is taken back.
|
||||
expect((await diditHook(event("Declined"))).status).toBe(204);
|
||||
expect(await adultOf(f)).toEqual({ 30: false });
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("revoked");
|
||||
});
|
||||
|
||||
test("Didit: under 18, and a decision fetched when the webhook has none", async () => {
|
||||
const g = await personWithAccounts("gus", [{ id: 40 }]);
|
||||
const started = (await call(g, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
const session = diditSessions.get(started.ref)!;
|
||||
session.finished = true;
|
||||
session.decision = { id_verifications: [{ date_of_birth: `${new Date().getUTCFullYear() - 17}-12-31` }] };
|
||||
const body = { webhook_type: "status.updated", session_id: started.ref, status: "Approved", timestamp: 0 };
|
||||
expect((await diditHook(body)).status).toBe(204);
|
||||
expect(await adultOf(g)).toEqual({ 40: false });
|
||||
expect((await call(g, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ status: "complete", overEighteen: false });
|
||||
});
|
||||
|
||||
test("checks are limited per person and by a provider's monthly allowance", async () => {
|
||||
const h = await personWithAccounts("hal", []);
|
||||
// Each abandoned check is a new one.
|
||||
for (let i = 0; i < attemptsPerPerson; i++) {
|
||||
const s = (await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
diditSessions.get(s.ref)!.finished = true;
|
||||
const body = { webhook_type: "status.updated", session_id: s.ref, status: "Abandoned", timestamp: 0 };
|
||||
expect((await diditHook(body)).status).toBe(204);
|
||||
}
|
||||
const limited = await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
|
||||
expect(limited).toMatchObject({ status: 409, json: { error: "refused" } });
|
||||
expect(limited.json.message).toContain("30 days");
|
||||
|
||||
// The month's allowance is everyone's (here a service with room for one more).
|
||||
const capped = new AgeService(db, [{ ...didit, monthlyLimit: (await startedThisMonth()) + 1 }]);
|
||||
await personWithAccounts("ivy", []);
|
||||
await personWithAccounts("joe", []);
|
||||
const ivy = await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`);
|
||||
await expect(capped.start({ userId: await userOf("joe") }, "didit", `${webURL}/account`)).rejects.toThrow("used up");
|
||||
// Ivy can still go back to hers.
|
||||
expect(await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`)).toEqual(ivy);
|
||||
});
|
||||
|
||||
test("birth dates to ages", () => {
|
||||
const day = new Date("2026-11-03T12:00:00Z");
|
||||
expect(ageOn("2008-11-03", day)).toBe(18);
|
||||
expect(ageOn("2008-11-04", day)).toBe(17);
|
||||
expect(ageOn("2008-12-01", day)).toBe(17);
|
||||
expect(ageOn("nonsense", day)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a moved account takes nobody's age with it", async () => {
|
||||
|
||||
@@ -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,203 @@
|
||||
// File blocks (docs/products.md): moderation blocks a file network-wide; products using it are removed, catalogs told
|
||||
// to delete it, and uploads with it refused.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
||||
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;
|
||||
|
||||
// Catalog addresses that answer /health (for address changes).
|
||||
const healthy = new Set<string>(["https://moved.example.test"]);
|
||||
|
||||
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, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({
|
||||
db,
|
||||
auth,
|
||||
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
|
||||
apiURL,
|
||||
webURL,
|
||||
catalogHealthy: async (url) => healthy.has(url),
|
||||
});
|
||||
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; name: string };
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = text;
|
||||
}
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
async function person(name: string): 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 }),
|
||||
});
|
||||
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
||||
}
|
||||
|
||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
||||
|
||||
/** A person's catalog, registered and approved; its key's Authorization header. */
|
||||
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
||||
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
||||
name,
|
||||
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
||||
contact: "[email protected]",
|
||||
visibility: "public",
|
||||
membership,
|
||||
});
|
||||
expect(reg.status).toBe(201);
|
||||
const id = reg.json.catalog.id as string;
|
||||
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
||||
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
||||
}
|
||||
|
||||
/** A product uploaded by a person to a catalog; its ID. */
|
||||
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const r = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(r.status).toBe(201);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
||||
return r.json.product as string;
|
||||
}
|
||||
|
||||
|
||||
/** A revision with these files, by a person, to a catalog: the answer. */
|
||||
async function uploadFiles(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, files: unknown[]) {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth);
|
||||
}
|
||||
|
||||
let admin: Person;
|
||||
let mod: Person;
|
||||
|
||||
test("the first account is the admin; a moderator", async () => {
|
||||
admin = await person("admin1");
|
||||
mod = await person("warden");
|
||||
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
|
||||
});
|
||||
|
||||
test("blocking a file removes the products using it, tells their catalogs, and refuses it in uploads", async () => {
|
||||
const owner = await person("shelfowner");
|
||||
const maker = await person("maker");
|
||||
const copier = await person("copier");
|
||||
const c = await catalogOf(owner, admin, "Block Shelf");
|
||||
const other = await catalogOf(copier, admin, "Copy Shelf");
|
||||
const logo = file("logo.png", 2).sha256;
|
||||
|
||||
const tee = await upload(maker, c, "Logo Tee", 1);
|
||||
const second = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id, product: tee });
|
||||
expect(
|
||||
(await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: second.json.token, files: [file("index.xml", 1), file("logo.png", 2)], rating: "general", parent: 0, name: "", notes: "Added the logo" }, c.auth)).status,
|
||||
).toBe(201);
|
||||
const copy = (await uploadFiles(copier, other, "Copy Tee", [file("index.xml", 5), file("logo.png", 2)])).json.product as string;
|
||||
const plain = await upload(maker, c, "Plain Tee", 3);
|
||||
|
||||
// Only those who can remove products hub-wide.
|
||||
for (const p of [owner, maker]) {
|
||||
expect((await call(p, "GET", "/api/v1/blocks")).status).toBe(403);
|
||||
expect((await call(p, "GET", `/api/v1/blocks/${logo}`)).status).toBe(403);
|
||||
expect((await call(p, "PUT", `/api/v1/blocks/${logo}`, { reason: "x" })).status).toBe(403);
|
||||
}
|
||||
expect((await call(owner, "GET", "/api/v1/console")).json.sections).not.toContain("blocks");
|
||||
expect((await call(mod, "GET", "/api/v1/console")).json.sections).toContain("blocks");
|
||||
expect((await call(mod, "GET", "/api/v1/blocks/not-a-hash")).status).toBe(400);
|
||||
|
||||
// What blocking it would remove.
|
||||
const preview = (await call(mod, "GET", `/api/v1/blocks/${logo}`)).json;
|
||||
expect(preview.block).toBeNull();
|
||||
expect(preview.products.map((p: any) => [p.name, p.creatorUsername, p.revisions, p.removed]).sort()).toEqual([
|
||||
["Copy Tee", "copier", [1], false],
|
||||
["Logo Tee", "maker", [2], false],
|
||||
]);
|
||||
|
||||
const blocked = await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Trademarked logo" });
|
||||
expect(blocked.status).toBe(200);
|
||||
expect(blocked.json.removed.map((p: any) => p.id).sort()).toEqual([copy, tee].sort());
|
||||
expect((await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Again" })).json.error).toBe("blocked");
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${copy}`)).status).toBe(410);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${plain}`)).status).toBe(200);
|
||||
expect((await call(mod, "GET", `/api/v1/blocks/${logo}`)).json.block).toMatchObject({ sha256: logo, reason: "Trademarked logo", by: "warden" });
|
||||
expect((await call(mod, "GET", "/api/v1/blocks")).json.blocks.map((b: any) => b.sha256)).toEqual([logo]);
|
||||
|
||||
// Each catalog that hosts it is told to delete it.
|
||||
for (const k of [c, other]) {
|
||||
const feed = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, k.auth)).json.changes;
|
||||
expect(feed.filter((x: any) => x.type === "block")).toEqual([{ type: "block", sha256: logo }]);
|
||||
}
|
||||
|
||||
// Creators are told, and may appeal the removal.
|
||||
const told = (await call(maker, "GET", "/api/v1/notifications")).json.notifications[0];
|
||||
expect(told).toMatchObject({ type: "product_removed", data: { product: tee, reason: "Trademarked logo" } });
|
||||
const removal = (await call(maker, "GET", `/api/v1/console/audit?action=product.remove`)).json.entries[0];
|
||||
expect(removal).toMatchObject({ target: tee, appealable: true, detail: { file: logo } });
|
||||
|
||||
// Uploads with it are refused, and moderators see who tried.
|
||||
const again = await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)]);
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.json.error).toBe("blocked");
|
||||
const tried = (await call(mod, "GET", "/api/v1/console/audit?action=file.blocked_upload")).json.entries[0];
|
||||
expect(tried).toMatchObject({ actor: "copier", tags: { catalog: other.id }, detail: { files: [{ name: "logo2.png", sha256: logo }] } });
|
||||
|
||||
// Unblocking lets it in again; the products it removed stay removed until restored.
|
||||
expect((await call(owner, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(403);
|
||||
expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`, { reason: "Licensed after all" })).status).toBe(204);
|
||||
expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(404);
|
||||
expect((await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)])).status).toBe(201);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410);
|
||||
expect((await call(mod, "POST", `/api/v1/products/${tee}/unremove`, { reason: "Licensed" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(200);
|
||||
expect((await call(mod, "GET", "/api/v1/console/audit?action=file.unblock")).json.entries[0]).toMatchObject({ target: logo, detail: { reason: "Licensed after all" } });
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
// Cases (docs/console.md): reports, rating reviews, deletion and removal requests, appeals; their threads, who sees
|
||||
// and decides them, what deciding does; and notifications.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
||||
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;
|
||||
|
||||
// Catalog addresses that answer /health (for address changes).
|
||||
const healthy = new Set<string>(["https://moved.example.test"]);
|
||||
|
||||
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, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({
|
||||
db,
|
||||
auth,
|
||||
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
|
||||
apiURL,
|
||||
webURL,
|
||||
catalogHealthy: async (url) => healthy.has(url),
|
||||
});
|
||||
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; name: string };
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = text;
|
||||
}
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
async function person(name: string): 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 }),
|
||||
});
|
||||
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
||||
}
|
||||
|
||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
||||
|
||||
/** A person's catalog, registered and approved; its key's Authorization header. */
|
||||
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
||||
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
||||
name,
|
||||
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
||||
contact: "[email protected]",
|
||||
visibility: "public",
|
||||
membership,
|
||||
});
|
||||
expect(reg.status).toBe(201);
|
||||
const id = reg.json.catalog.id as string;
|
||||
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
||||
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
||||
}
|
||||
|
||||
/** A product uploaded by a person to a catalog; its ID. */
|
||||
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const r = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(r.status).toBe(201);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
||||
return r.json.product as string;
|
||||
}
|
||||
|
||||
|
||||
let admin: Person;
|
||||
let mod: Person;
|
||||
|
||||
const notes = async (p: Person) => (await call(p, "GET", "/api/v1/notifications")).json;
|
||||
|
||||
test("the first account is the admin; a moderator", async () => {
|
||||
admin = await person("admin1");
|
||||
mod = await person("warden");
|
||||
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
|
||||
});
|
||||
|
||||
test("reports: anyone signed in reports; the hosting catalog's team and moderators decide; who reported stays private", async () => {
|
||||
const owner = await person("shelfowner");
|
||||
const maker = await person("maker");
|
||||
const reporter = await person("reporter");
|
||||
const outsider = await person("outsider");
|
||||
const c = await catalogOf(owner, admin, "Report Shelf");
|
||||
const hat = await upload(maker, c, "Odd Hat", 1);
|
||||
|
||||
expect((await call(undefined, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "x" })).status).toBe(401);
|
||||
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: " " })).json.error).toBe("reason_needed");
|
||||
const made = await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Copied from another creator" });
|
||||
expect(made.status).toBe(201);
|
||||
const id = made.json.case.id;
|
||||
expect(made.json.case).toMatchObject({ kind: "report", status: "open", tags: { product: hat }, tagNames: { [hat]: "Odd Hat" } });
|
||||
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Again" })).json.error).toBe("already_reported");
|
||||
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: "reporter" }, reason: "Me" })).status).toBe(400);
|
||||
|
||||
// Who sees it: the reporter, the hosting catalog's team, moderators. Not its creator, not others.
|
||||
const ids = async (p: Person) => (await call(p, "GET", "/api/v1/cases")).json.cases.map((x: any) => x.id);
|
||||
expect(await ids(reporter)).toEqual([id]);
|
||||
expect(await ids(owner)).toContain(id);
|
||||
expect(await ids(mod)).toContain(id);
|
||||
expect(await ids(maker)).not.toContain(id);
|
||||
expect((await call(maker, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
|
||||
expect((await call(outsider, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
|
||||
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: true, canAssign: false });
|
||||
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: false, case: { openedBy: "reporter" } });
|
||||
// The catalog's team decides it without knowing who reported; moderators know.
|
||||
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("");
|
||||
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("reporter");
|
||||
expect((await call(owner, "GET", "/api/v1/console")).json.todo.cases).toBe(1);
|
||||
expect((await call(owner, "GET", "/api/v1/console")).json.sections).toContain("cases");
|
||||
|
||||
// A thread: replies reach the reporter; internal notes stay with those deciding.
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Looking into it" })).status).toBe(200);
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Probably fine", internal: true })).status).toBe(200);
|
||||
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => n.body)).toEqual(["Looking into it"]);
|
||||
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => [n.body, n.internal])).toEqual([
|
||||
["Looking into it", false],
|
||||
["Probably fine", true],
|
||||
]);
|
||||
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_note", data: { case: id, by: "shelfowner" } });
|
||||
|
||||
// Assigning is for moderators, to moderators.
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${id}/assign`, { to: "shelfowner" })).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "outsider" })).json.error).toBe("invalid");
|
||||
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "warden" })).json.case.assignee).toBe("warden");
|
||||
|
||||
// Deciding: the catalog's owner declines it; the reporter is told; it's done.
|
||||
expect((await call(outsider, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
|
||||
const decided = await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "It's an original." });
|
||||
expect(decided.json.case).toMatchObject({ status: "declined", resolution: "It's an original.", decidedBy: "shelfowner" });
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).json.error).toBe("decided");
|
||||
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_decided", data: { case: id, status: "declined" } });
|
||||
expect((await call(reporter, "GET", "/api/v1/me")).json.person.unread).toBe(2);
|
||||
expect((await call(reporter, "POST", "/api/v1/notifications/read", {})).json.unread).toBe(0);
|
||||
// The audit log keeps who reported out of what the product's creator can read.
|
||||
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${hat}`)).json.entries.map((e: any) => e.action);
|
||||
expect(audit).not.toContain("case.open");
|
||||
|
||||
// Reports are limited: ten an hour.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const p = await person(`spam${i}`);
|
||||
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: p.name }, reason: "Spam" })).status).toBe(201);
|
||||
}
|
||||
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { catalog: c.id }, reason: "One more" })).status).toBe(429);
|
||||
});
|
||||
|
||||
test("rating reviews: a creator lowering a rating asks moderators; resolving lowers it", async () => {
|
||||
const owner = await person("reviewshelf");
|
||||
const maker = await person("rater");
|
||||
const c = await catalogOf(owner, admin, "Review Shelf");
|
||||
const top = await upload(maker, c, "Plain Top", 2);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "adult" })).json.review).toBeNull();
|
||||
|
||||
// Lowering it opens a case (one at a time); other fields in the same edit still change.
|
||||
const asked = await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general", name: "Plain Tee", reason: "Nothing adult about it" });
|
||||
expect(asked.status).toBe(200);
|
||||
expect(asked.json.product).toMatchObject({ rating: "adult", name: "Plain Tee" });
|
||||
const review = asked.json.review as number;
|
||||
expect(review).toBeGreaterThan(0);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general" })).json.review).toBe(review);
|
||||
const kase = (await call(maker, "GET", `/api/v1/cases/${review}`)).json;
|
||||
expect(kase.case).toMatchObject({ kind: "rating-review", reason: "Nothing adult about it", openedBy: "rater" });
|
||||
expect(kase.canDecide).toBe(false);
|
||||
// Its catalog's team doesn't see or decide ratings; moderators do.
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
|
||||
expect((await call(mod, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "Agreed" })).json.case.status).toBe("resolved");
|
||||
const history = (await call(maker, "GET", `/api/v1/products/${top}/history`)).json.entries;
|
||||
expect(history[0]).toMatchObject({ by: "warden", reason: "Agreed", changes: { rating: { from: "adult", to: "general" } } });
|
||||
const types = (await notes(maker)).notifications.map((n: any) => n.type);
|
||||
expect(types).toContain("case_decided");
|
||||
expect(types).toContain("product_edited");
|
||||
|
||||
// Files rated adult can't be lowered by anyone: the creator is told at once.
|
||||
const lace = await upload(maker, c, "Lace", 3, "adult");
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${lace}`, { rating: "general" })).json.error).toBe("files_adult");
|
||||
});
|
||||
|
||||
test("a catalog's deletion goes to moderators; resolving it deletes the catalog", async () => {
|
||||
const owner = await person("leaver");
|
||||
const operator = await person("leaverop");
|
||||
const c = await catalogOf(owner, admin, "Leaving Shelf");
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/leaverop?role=catalog-operator`, { role: "catalog-operator" })).status).toBe(200);
|
||||
expect((await call(operator, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Done" })).json.missing.permission).toBe("catalog:delete-request");
|
||||
const first = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Closing shop" })).json.case;
|
||||
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Again" })).json).toMatchObject({ error: "requested", case: first });
|
||||
// Its team sees the case; a decline leaves the catalog as it was.
|
||||
expect((await call(operator, "GET", `/api/v1/cases/${first}`)).json.case.kind).toBe("catalog-deletion");
|
||||
expect((await call(mod, "POST", `/api/v1/cases/${first}/decide`, { decision: "decline", resolution: "Move your products first" })).status).toBe(200);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("approved");
|
||||
const second = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Moved them" })).json.case;
|
||||
expect((await call(mod, "POST", `/api/v1/cases/${second}/decide`, { decision: "resolve", resolution: "" })).status).toBe(200);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("deleted");
|
||||
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
|
||||
});
|
||||
|
||||
test("removal requests: a catalog's team asks; moderators remove the product everywhere", async () => {
|
||||
const owner = await person("cautious");
|
||||
const maker = await person("copier");
|
||||
const c = await catalogOf(owner, admin, "Careful Shelf");
|
||||
const shoes = await upload(maker, c, "Copied Shoes", 4);
|
||||
expect((await call(maker, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "x" })).status).toBe(403);
|
||||
const asked = await call(owner, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "Stolen work" });
|
||||
expect(asked.status).toBe(201);
|
||||
expect((await call(owner, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(403);
|
||||
expect((await call(mod, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "Confirmed" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${shoes}`)).status).toBe(410);
|
||||
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { product: shoes, reason: "Confirmed" } });
|
||||
});
|
||||
|
||||
test("appeals: the person affected appeals an action once; another moderator decides", async () => {
|
||||
const owner = await person("appealshelf");
|
||||
const maker = await person("appealer");
|
||||
const c = await catalogOf(owner, admin, "Appeal Shelf");
|
||||
const coat = await upload(maker, c, "Fine Coat", 5);
|
||||
expect((await call(mod, "POST", `/api/v1/products/${coat}/remove`, { reason: "Looks stolen" })).status).toBe(200);
|
||||
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { name: "Fine Coat", reason: "Looks stolen" } });
|
||||
|
||||
// The creator's audit log offers the removal for appeal; others can't appeal it.
|
||||
const entry = (await call(maker, "GET", `/api/v1/console/audit?product=${coat}`)).json.entries.find((e: any) => e.action === "product.remove");
|
||||
expect(entry.appealable).toBe(true);
|
||||
expect((await call(owner, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "x" })).status).toBe(404);
|
||||
const appeal = await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "I made it from scratch" });
|
||||
expect(appeal.json.case).toMatchObject({ kind: "appeal", data: { action: "product.remove" } });
|
||||
expect((await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "Again" })).json.error).toBe("appealed");
|
||||
|
||||
// The moderator who removed it doesn't decide the appeal; an admin may (flagged in the audit log when it was theirs).
|
||||
const id = appeal.json.case.id;
|
||||
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.canDecide).toBe(false);
|
||||
expect((await call(mod, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "" })).json.message).toContain("Another moderator");
|
||||
expect((await call(admin, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "Restoring it" })).json.case.status).toBe("resolved");
|
||||
// Upholding it records the decision; the moderator restores the product (only moderators can).
|
||||
expect((await call(maker, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, { reason: "Appeal upheld" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${coat}`)).status).toBe(200);
|
||||
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(404);
|
||||
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_restored", data: { reason: "Appeal upheld" } });
|
||||
});
|
||||
|
||||
test("membership decisions and moderators' edits reach people as notifications", async () => {
|
||||
const owner = await person("gatekeeper");
|
||||
const joiner = await person("joiner");
|
||||
const c = await catalogOf(owner, admin, "Gated Shelf", "approval");
|
||||
expect((await call(joiner, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/joiner`, { status: "member" })).status).toBe(200);
|
||||
expect((await notes(joiner)).notifications[0]).toMatchObject({ type: "membership_decided", data: { catalog: c.id, status: "member" } });
|
||||
|
||||
const maker = await person("edited");
|
||||
const p = await upload(maker, c, "Wrong Name", 6);
|
||||
expect((await call(mod, "PATCH", `/api/v1/products/${p}`, { name: "Right Name", reason: "Typo" })).status).toBe(200);
|
||||
expect((await notes(maker)).notifications[0]).toMatchObject({
|
||||
type: "product_edited",
|
||||
data: { product: p, name: "Right Name", fields: ["name"], reason: "Typo", by: "warden" },
|
||||
});
|
||||
// Their own edits don't notify them.
|
||||
const before = (await notes(maker)).notifications.length;
|
||||
await call(maker, "PATCH", `/api/v1/products/${p}`, { tags: ["mine"] });
|
||||
expect((await notes(maker)).notifications.length).toBe(before);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and
|
||||
// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
||||
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;
|
||||
|
||||
// Catalog addresses that answer /health (for address changes).
|
||||
const healthy = new Set<string>(["https://moved.example.test"]);
|
||||
|
||||
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, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({
|
||||
db,
|
||||
auth,
|
||||
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
|
||||
apiURL,
|
||||
webURL,
|
||||
catalogHealthy: async (url) => healthy.has(url),
|
||||
});
|
||||
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; name: string };
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = text;
|
||||
}
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
async function person(name: string): 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 }),
|
||||
});
|
||||
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
||||
}
|
||||
|
||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
||||
|
||||
/** A person's catalog, registered and approved; its key's Authorization header. */
|
||||
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
||||
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
||||
name,
|
||||
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
||||
contact: "[email protected]",
|
||||
visibility: "public",
|
||||
membership,
|
||||
});
|
||||
expect(reg.status).toBe(201);
|
||||
const id = reg.json.catalog.id as string;
|
||||
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
||||
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
||||
}
|
||||
|
||||
/** A product uploaded by a person to a catalog; its ID. */
|
||||
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const r = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(r.status).toBe(201);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
||||
return r.json.product as string;
|
||||
}
|
||||
|
||||
let admin: Person;
|
||||
|
||||
test("the first account is the admin", async () => {
|
||||
admin = await person("admin1");
|
||||
expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin");
|
||||
});
|
||||
|
||||
test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => {
|
||||
const owner = await person("owner1");
|
||||
const stranger = await person("stranger1");
|
||||
const c = await catalogOf(owner, admin, "Owner Shelf");
|
||||
|
||||
// Anyone can see an approved catalog; the owner sees their permissions there.
|
||||
const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`);
|
||||
expect(seen.status).toBe(200);
|
||||
expect(seen.json.roles).toContain("catalog-owner");
|
||||
expect(seen.json.permissions).toContain("catalog:edit");
|
||||
expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]);
|
||||
|
||||
// A stranger is refused, naming what's missing.
|
||||
const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" });
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } });
|
||||
|
||||
// The owner changes settings; an address must answer /health.
|
||||
const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable");
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe(
|
||||
"https://moved.example.test",
|
||||
);
|
||||
|
||||
// A new key: the old one stops working at once.
|
||||
const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`);
|
||||
expect(rotated.status).toBe(200);
|
||||
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
|
||||
expect(
|
||||
(await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("teams: the owner gives the operator role; operators manage members but not the team", async () => {
|
||||
const owner = await person("owner2");
|
||||
const op = await person("op2");
|
||||
const other = await person("other2");
|
||||
const c = await catalogOf(owner, admin, "Team Shelf", "approval");
|
||||
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403);
|
||||
const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" });
|
||||
expect(added.status).toBe(200);
|
||||
expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]);
|
||||
|
||||
// The operator holds member:approve, not team:manage.
|
||||
const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json;
|
||||
expect(perms.roles).toEqual(["catalog-operator"]);
|
||||
expect(perms.permissions).toContain("member:approve");
|
||||
expect(perms.permissions).not.toContain("team:manage");
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe(
|
||||
"team:manage",
|
||||
);
|
||||
|
||||
// An approval catalog: other asks, the operator approves.
|
||||
expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
|
||||
const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`);
|
||||
expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]);
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200);
|
||||
expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member");
|
||||
|
||||
// Operators leave on their own; the console lists the catalog for them while they're on the team.
|
||||
expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]);
|
||||
expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200);
|
||||
expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403);
|
||||
});
|
||||
|
||||
test("membership gates getting and wearing products; seeing them doesn't need it", async () => {
|
||||
const owner = await person("owner3");
|
||||
const maker = await person("maker3");
|
||||
const fan = await person("fan3");
|
||||
const c = await catalogOf(owner, admin, "Invite Shelf", "invite");
|
||||
// Uploads to an invite catalog: anyone may upload by default.
|
||||
const pid = await upload(maker, c, "Hat", 31);
|
||||
|
||||
// Seeing works; getting doesn't, and says which catalogs host it.
|
||||
const page = await call(fan, "GET", `/api/v1/products/${pid}`);
|
||||
expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] });
|
||||
expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200);
|
||||
const refused = await call(fan, "PUT", `/api/v1/library/${pid}`);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] });
|
||||
|
||||
// Invite only: joining without an invite is refused; with one, they're in.
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only");
|
||||
const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 });
|
||||
expect(inv.status).toBe(201);
|
||||
expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`);
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member");
|
||||
const late = await person("late3");
|
||||
expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid");
|
||||
|
||||
expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
||||
|
||||
// Banned: the product stays in the table but isn't listed (or wearable) until they're back.
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]);
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned");
|
||||
expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204);
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
||||
|
||||
// Its creator always has their own product.
|
||||
expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
||||
});
|
||||
|
||||
test("a catalog's upload policy and rating ceiling apply to uploads", async () => {
|
||||
const owner = await person("owner4");
|
||||
const maker = await person("maker4");
|
||||
const c = await catalogOf(owner, admin, "Strict Shelf");
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200);
|
||||
|
||||
const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } });
|
||||
expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member");
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const adult = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } });
|
||||
|
||||
// Team only: members can't either.
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200);
|
||||
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403);
|
||||
expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200);
|
||||
});
|
||||
|
||||
test("delisting and suspension reach the catalog through its changes feed", async () => {
|
||||
const owner = await person("owner5");
|
||||
const maker = await person("maker5");
|
||||
const c = await catalogOf(owner, admin, "Feed Shelf");
|
||||
const pid = await upload(maker, c, "Scarf", 51);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]);
|
||||
|
||||
expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403);
|
||||
const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`);
|
||||
expect(delisted.json.revisions).toEqual([1]);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]);
|
||||
|
||||
const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth);
|
||||
expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]);
|
||||
const cursor = feed.json.cursor;
|
||||
expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]);
|
||||
|
||||
// Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still
|
||||
// reads its feed, and is told.
|
||||
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({
|
||||
status: "suspended",
|
||||
statusReason: "spam",
|
||||
});
|
||||
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404);
|
||||
const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth);
|
||||
expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]);
|
||||
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved");
|
||||
});
|
||||
|
||||
test("the console is for everyone who controls something; its audit log shows only what they may see", async () => {
|
||||
const owner = await person("owner6");
|
||||
const maker = await person("maker6");
|
||||
const nobody = await person("nobody6");
|
||||
const c = await catalogOf(owner, admin, "Audit Shelf");
|
||||
const other = await catalogOf(await person("owner6b"), admin, "Other Shelf");
|
||||
const pid = await upload(maker, c, "Gloves", 61);
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "[email protected]" })).status).toBe(200);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200);
|
||||
expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200);
|
||||
|
||||
// Sections by what they control.
|
||||
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
|
||||
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
|
||||
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
|
||||
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "cases", "audit"]);
|
||||
expect(ownerConsole.moderation).toBeNull();
|
||||
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "cases", "audit"]);
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "cases", "people", "blocks", "audit", "settings"]);
|
||||
|
||||
// The owner sees their catalog's entries, not the other catalog's; the maker sees their product's.
|
||||
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
|
||||
expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true);
|
||||
expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false);
|
||||
expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf");
|
||||
const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries;
|
||||
expect(makerLog.map((e: any) => e.action)).toContain("product.edit");
|
||||
expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false);
|
||||
|
||||
// Admins see everything, and filter.
|
||||
const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries;
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" });
|
||||
expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true);
|
||||
expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("outfits relay only the hub products the wearer may wear; local packs pass", async () => {
|
||||
const { Realtime } = await import("../src/realtime");
|
||||
const rt = new Realtime({
|
||||
owner: async () => "u1",
|
||||
wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")),
|
||||
});
|
||||
rt.open("s1", "u1", () => {});
|
||||
await rt.message("s1", { type: "session", imvu_id: 7 });
|
||||
await rt.message("s1", {
|
||||
type: "outfit",
|
||||
items: [
|
||||
{ id: "aaaaaaaaaa", name: "Mine", adult: false },
|
||||
{ id: "bbbbbbbbbb", name: "Members only", adult: false },
|
||||
{ id: "local-pack-1", name: "A pack", adult: false },
|
||||
],
|
||||
});
|
||||
expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -229,8 +229,8 @@ describe("T5DE's proxy", () => {
|
||||
const admin = new Browser();
|
||||
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
|
||||
const consoleView = (await admin.call("GET", "/api/v1/console")).json;
|
||||
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
|
||||
expect(consoleView.counts.computers).toBe(1);
|
||||
expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
|
||||
expect(consoleView.moderation.counts.computers).toBe(1);
|
||||
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
||||
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
||||
expect(online).toEqual([
|
||||
|
||||
@@ -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);
|
||||
@@ -107,10 +122,22 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
||||
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
|
||||
const made = await revision(up.json.token);
|
||||
expect(made.status).toBe(201);
|
||||
expect(made.json.revision).toBe(1);
|
||||
expect(made.json).toMatchObject({ revision: 1, draft: true });
|
||||
const id = made.json.product as string;
|
||||
expect(id).toMatch(/^[a-z2-7]{10}$/);
|
||||
|
||||
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
|
||||
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
|
||||
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
|
||||
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
|
||||
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
|
||||
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
|
||||
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
|
||||
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
|
||||
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
|
||||
|
||||
// Its page: info, revisions (files sorted), hosting.
|
||||
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
|
||||
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
|
||||
@@ -132,7 +159,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
||||
expect((await revision(up2)).status).toBe(400);
|
||||
expect((await revision(up2, { notes: "same" })).status).toBe(409);
|
||||
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
|
||||
expect(r2.json).toEqual({ product: id, revision: 2 });
|
||||
expect(r2.json).toEqual({ product: id, revision: 2, draft: false });
|
||||
// Someone else's upload token can't add to it.
|
||||
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
|
||||
|
||||
@@ -159,15 +186,277 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
||||
// Adult products are only for adults.
|
||||
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
|
||||
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
|
||||
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
|
||||
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
|
||||
// Its creator and moderators see it without an adult account; getting or wearing it still needs one.
|
||||
expect((await call(admin, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
|
||||
expect((await call(admin, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
|
||||
expect((await call(admin, "GET", `/api/v1/products/${adultID}/files`)).status).toBe(404);
|
||||
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
|
||||
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
|
||||
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
|
||||
|
||||
// Lowering a rating waits for review.
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed");
|
||||
// Its files are rated adult, so it can't be lowered (a revision rated general can be reviewed: cases.test.ts).
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("files_adult");
|
||||
|
||||
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);
|
||||
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
|
||||
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);
|
||||
|
||||
// A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket
|
||||
// made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product).
|
||||
imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) });
|
||||
const jacketID = await upload("Dresser Jacket", 7004);
|
||||
for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20);
|
||||
const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json;
|
||||
expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]);
|
||||
expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401);
|
||||
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid");
|
||||
const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 });
|
||||
expect(filed.json.fields).toEqual(["category"]);
|
||||
expect(filed.json.product.categories).toEqual(coat.category_path);
|
||||
expect(filed.json.product.category).toEqual(coat.category_path);
|
||||
expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]);
|
||||
// Back to its parent's: the history keeps paths, and undoing puts the chosen one back.
|
||||
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent);
|
||||
expect(await names("&category=716")).toEqual(["Dresser Coat"]);
|
||||
const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries;
|
||||
expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null });
|
||||
expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path);
|
||||
|
||||
// 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);
|
||||
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
|
||||
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]);
|
||||
});
|
||||
|
||||
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
|
||||
const { admin, catalogID, auth } = shared;
|
||||
const maker = await person("tailor", { id: 521, ageVerified: true });
|
||||
const other = await person("browser", { id: 522, ageVerified: true });
|
||||
const mod = await person("warden", { id: 523, ageVerified: true });
|
||||
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
|
||||
|
||||
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
|
||||
|
||||
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
|
||||
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
|
||||
expect(set.json.fields).toEqual(["tags", "listed"]);
|
||||
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
|
||||
await call(maker, "POST", `/api/v1/products/${id}/publish`);
|
||||
|
||||
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
|
||||
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
|
||||
expect(await names(other, "creator=tailor")).toEqual([]);
|
||||
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
|
||||
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
|
||||
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
||||
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
|
||||
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
|
||||
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
|
||||
expect(await names(other, "tag=denim")).toEqual([]);
|
||||
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
|
||||
|
||||
// Others can't edit; a moderator can, with a reason the creator sees.
|
||||
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
|
||||
error: "forbidden",
|
||||
missing: { permission: "product:edit" },
|
||||
});
|
||||
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
|
||||
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
|
||||
|
||||
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
|
||||
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
|
||||
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
|
||||
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
|
||||
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
|
||||
expect(page.product.locked).toEqual(["name"]);
|
||||
expect(page.permissions).toContain("product:edit");
|
||||
expect(page.permissions).not.toContain("product:edit-locked");
|
||||
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
|
||||
|
||||
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json).toMatchObject({
|
||||
product: { rating: "adult" },
|
||||
review: expect.any(Number),
|
||||
});
|
||||
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
|
||||
|
||||
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
|
||||
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
|
||||
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
|
||||
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
|
||||
["edit", "rating", "warden", false],
|
||||
["edit", "rating", "tailor", true],
|
||||
["edit", "description", "tailor", true],
|
||||
["lock", "locked", "warden", false],
|
||||
["edit", "name", "warden", false],
|
||||
["edit", "listed", "tailor", true],
|
||||
["publish", "status", "tailor", true],
|
||||
["edit", "tags,listed", "tailor", true],
|
||||
]);
|
||||
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
|
||||
|
||||
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
|
||||
const rename = history[4].id;
|
||||
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
|
||||
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
|
||||
expect(reverted.json.product.name).toBe("Vest");
|
||||
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
|
||||
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
|
||||
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
|
||||
|
||||
// Moderators' edits are in the creator's audit log, tagged with the product.
|
||||
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
|
||||
expect(audit.map((e: any) => e.action)).toContain("product.lock");
|
||||
|
||||
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
|
||||
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
|
||||
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
|
||||
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
|
||||
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
|
||||
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
|
||||
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
|
||||
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
|
||||
|
||||
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
|
||||
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
|
||||
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
|
||||
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
|
||||
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
|
||||
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
|
||||
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's
|
||||
// approval); admins switch it in the console, and the environment gives the default.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient } from "../src/imvu";
|
||||
import { Settings } from "../src/settings";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
// Closed by default (as on staging), until an admin changes it.
|
||||
const settings = new Settings(db, { registration: "closed" });
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings });
|
||||
const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch);
|
||||
const hub = createApp({ db, auth, imvu, webURL, settings });
|
||||
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
stop = () => server.stop();
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
stop();
|
||||
await close();
|
||||
});
|
||||
|
||||
async function call(cookie: { v: string }, method: string, path: string, body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const set = res.headers.getSetCookie();
|
||||
if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; ");
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
async function signUp(name: string) {
|
||||
const cookie = { v: "" };
|
||||
expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
test("closed: new accounts wait for approval; the first one runs the hub", async () => {
|
||||
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" });
|
||||
const admin = await signUp("ada");
|
||||
expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true });
|
||||
expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200);
|
||||
|
||||
const bo = await signUp("bo_");
|
||||
// Signed in, but nothing else.
|
||||
expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false });
|
||||
expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } });
|
||||
expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403);
|
||||
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
|
||||
|
||||
// Moderators see who's waiting, and approve them.
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.moderation.counts.waiting).toBe(1);
|
||||
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
|
||||
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
|
||||
expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200);
|
||||
expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]);
|
||||
// An approved account can't be declined.
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400);
|
||||
});
|
||||
|
||||
test("declining deletes the account", async () => {
|
||||
const admin = { v: "" };
|
||||
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
|
||||
const cy = await signUp("cyd");
|
||||
const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
|
||||
expect(p.username).toBe("cyd");
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200);
|
||||
expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401);
|
||||
expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401);
|
||||
});
|
||||
|
||||
test("only admins open or close registration", async () => {
|
||||
const admin = { v: "" };
|
||||
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
|
||||
const mod = await signUp("dot");
|
||||
const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0];
|
||||
await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`);
|
||||
await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" });
|
||||
expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" });
|
||||
expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403);
|
||||
|
||||
expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" });
|
||||
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" });
|
||||
// Open: new accounts can use the hub at once.
|
||||
const eve = await signUp("eve");
|
||||
expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200);
|
||||
const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action);
|
||||
expect(audit).toContain("hub.registration");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// API tokens (docs/console.md): made from a session, limited to their grants and to what their owner holds now,
|
||||
// usable only on routes that check permissions, audited by name, revocable.
|
||||
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 apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
||||
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;
|
||||
|
||||
// Catalog addresses that answer /health (for address changes).
|
||||
const healthy = new Set<string>(["https://moved.example.test"]);
|
||||
|
||||
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, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({
|
||||
db,
|
||||
auth,
|
||||
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
|
||||
apiURL,
|
||||
webURL,
|
||||
catalogHealthy: async (url) => healthy.has(url),
|
||||
});
|
||||
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; name: string };
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = text;
|
||||
}
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
async function person(name: string): 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 }),
|
||||
});
|
||||
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
||||
}
|
||||
|
||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
||||
|
||||
/** A person's catalog, registered and approved; its key's Authorization header. */
|
||||
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
||||
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
||||
name,
|
||||
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
||||
contact: "[email protected]",
|
||||
visibility: "public",
|
||||
membership,
|
||||
});
|
||||
expect(reg.status).toBe(201);
|
||||
const id = reg.json.catalog.id as string;
|
||||
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
||||
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
||||
}
|
||||
|
||||
/** A product uploaded by a person to a catalog; its ID. */
|
||||
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const r = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(r.status).toBe(201);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
||||
return r.json.product as string;
|
||||
}
|
||||
|
||||
|
||||
/** A revision with these files, by a person, to a catalog: the answer. */
|
||||
async function uploadFiles(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, files: unknown[]) {
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth);
|
||||
}
|
||||
|
||||
let admin: Person;
|
||||
let mod: Person;
|
||||
|
||||
test("the first account is the admin; a moderator", async () => {
|
||||
admin = await person("admin1");
|
||||
mod = await person("warden");
|
||||
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
|
||||
});
|
||||
|
||||
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||
const at = (type: "product" | "catalog", id: string) => ({ type, id });
|
||||
|
||||
test("tokens: made from a session, limited to their grants, usable only where permissions are checked", async () => {
|
||||
const owner = await person("shelfowner");
|
||||
const maker = await person("maker");
|
||||
const c = await catalogOf(owner, admin, "Token Shelf");
|
||||
const hat = await upload(maker, c, "Hat", 1);
|
||||
const scarf = await upload(maker, c, "Scarf", 2);
|
||||
const theirs = await upload(owner, c, "Owner's Coat", 3);
|
||||
|
||||
// Only what the person holds now.
|
||||
const make = (p: Person, grants: unknown[], name = "script", days = 30) => call(p, "POST", "/api/v1/account/tokens", { name, days, grants });
|
||||
expect((await make(maker, [{ permission: "product:edit", scope: at("product", theirs) }])).json.error).toBe("not_held");
|
||||
expect((await make(maker, [{ permission: "product:remove", scope: { type: "hub" } }])).json.error).toBe("not_held");
|
||||
expect((await make(maker, [{ permission: "no:such", scope: at("product", hat) }])).status).toBe(400);
|
||||
expect((await make(maker, [])).status).toBe(400);
|
||||
expect((await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "x", 400)).status).toBe(400);
|
||||
expect((await make(undefined as unknown as Person, [{ permission: "product:edit", scope: at("product", hat) }])).status).toBe(401);
|
||||
|
||||
const made = await make(maker, [
|
||||
{ permission: "product:edit", scope: at("product", hat) },
|
||||
{ permission: "revision:upload", scope: at("product", hat) },
|
||||
{ permission: "product:edit", scope: at("product", hat) },
|
||||
], "Hat updater");
|
||||
expect(made.status).toBe(201);
|
||||
const tok = made.json.token as string;
|
||||
expect(tok).toMatch(/^t5de_pat_[A-Za-z0-9_-]{43}$/);
|
||||
expect(made.json.info).toMatchObject({ name: "Hat updater", status: "active", lastUsedAt: null });
|
||||
expect(made.json.info.grants).toEqual([
|
||||
{ permission: "product:edit", scope: at("product", hat), name: "Hat" },
|
||||
{ permission: "revision:upload", scope: at("product", hat), name: "Hat" },
|
||||
]);
|
||||
expect(JSON.stringify(made.json.info)).not.toContain(tok);
|
||||
|
||||
// What it grants, there.
|
||||
const asToken = (method: string, path: string, body?: unknown) => call(undefined, method, path, body, bearer(tok));
|
||||
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "By script" })).status).toBe(200);
|
||||
const other = await asToken("PATCH", `/api/v1/products/${scarf}`, { description: "Nope" });
|
||||
expect(other.status).toBe(403);
|
||||
expect(other.json.missing).toEqual({ permission: "product:edit", scope: { product: scarf } });
|
||||
expect((await asToken("POST", `/api/v1/products/${hat}/publish`)).status).toBe(403);
|
||||
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: hat })).status).toBe(200);
|
||||
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: scarf })).status).toBe(403);
|
||||
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id })).json.error).toBe("token_not_allowed");
|
||||
|
||||
// Nothing else: session-only routes refuse it, and it can't make tokens; elsewhere it's signed out.
|
||||
expect((await asToken("GET", "/api/v1/me")).json.error).toBe("token_not_allowed");
|
||||
expect((await asToken("POST", "/api/v1/account/tokens", { name: "more", days: 1, grants: [{ permission: "product:edit", scope: at("product", hat) }] })).json.error).toBe("token_not_allowed");
|
||||
expect((await asToken("DELETE", `/api/v1/products/${hat}`)).json.error).toBe("token_not_allowed");
|
||||
expect((await asToken("POST", "/api/v1/cases", { kind: "report", about: { product: theirs }, reason: "x" })).json.error).toBe("token_not_allowed");
|
||||
|
||||
// Audited as the person, by token; listed with its last use.
|
||||
const entry = (await call(maker, "GET", `/api/v1/console/audit?action=product.edit&product=${hat}`)).json.entries[0];
|
||||
expect(entry).toMatchObject({ actor: "maker", detail: { token: "Hat updater" } });
|
||||
const listed = (await call(maker, "GET", "/api/v1/account/tokens")).json.tokens;
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].lastUsedAt).not.toBeNull();
|
||||
expect((await call(owner, "GET", "/api/v1/account/tokens")).json.tokens).toEqual([]);
|
||||
|
||||
// Revoked or expired: refused.
|
||||
expect((await call(owner, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(404);
|
||||
expect((await call(maker, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(204);
|
||||
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "Again" })).json.error).toBe("bad_token");
|
||||
expect((await call(maker, "GET", "/api/v1/account/tokens")).json.tokens[0].status).toBe("revoked");
|
||||
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer("t5de_pat_nope"))).json.error).toBe("bad_token");
|
||||
|
||||
const short = (await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "short", 1)).json;
|
||||
await db.update(schema.apiToken).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.apiToken.id, short.info.id));
|
||||
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer(short.token))).json.error).toBe("bad_token");
|
||||
});
|
||||
|
||||
test("tokens never have more than their owner holds now", async () => {
|
||||
const made = await call(mod, "POST", "/api/v1/account/tokens", { name: "blocks", days: 7, grants: [{ permission: "product:remove", scope: { type: "hub" } }] });
|
||||
expect(made.status).toBe(201);
|
||||
const tok = made.json.token as string;
|
||||
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(200);
|
||||
// Its grants don't reach other permissions the moderator holds.
|
||||
expect((await call(undefined, "GET", `/api/v1/catalogs/${(await call(mod, "GET", "/api/v1/console/catalogs")).json.catalogs[0].id}/team`, undefined, bearer(tok))).status).toBe(403);
|
||||
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "user" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(403);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, ApiError, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const done = () => revalidatePath("/account");
|
||||
@@ -58,3 +59,46 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
|
||||
return "Signed out everywhere.";
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts an age check with a provider that checks the person, and sends them to its page. */
|
||||
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
let url: string | undefined;
|
||||
const state = await attempt(async () => {
|
||||
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
|
||||
if (started.kind === "redirect") url = started.url;
|
||||
done();
|
||||
});
|
||||
if (url) redirect(url);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** What making a token answers: the token itself, shown once. */
|
||||
export type TokenState = { error?: string; token?: string; name?: string } | null;
|
||||
|
||||
/** Makes an API token. `grants` is JSON: [{permission, scope}]. */
|
||||
export async function createToken(_: TokenState, form: FormData): Promise<TokenState> {
|
||||
let grants: { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } }[];
|
||||
try {
|
||||
grants = JSON.parse(field(form, "grants") || "[]");
|
||||
} catch {
|
||||
return { error: "Choose what it may do." };
|
||||
}
|
||||
if (!Array.isArray(grants) || grants.length === 0) return { error: "Choose what it may do." };
|
||||
try {
|
||||
const made = unwrap(await api.account.tokens.post({ name: field(form, "name"), days: Number(field(form, "days")), grants }));
|
||||
done();
|
||||
return { token: made.token, name: made.info.name };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes an API token. */
|
||||
export async function revokeToken(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(await api.account.tokens({ id: field(form, "id") }).delete());
|
||||
done();
|
||||
return "Revoked.";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { Tag } from "@/components/status";
|
||||
import { TimeAgo } from "@/components/time";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { AgeInfo } from "@/lib/api";
|
||||
import { startAgeCheck } from "./actions";
|
||||
|
||||
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
|
||||
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
|
||||
const ways = age.providers.filter((p) => p.scope === "person");
|
||||
if (ways.length === 0) return null;
|
||||
const now = Date.now();
|
||||
const verified = age.checks.find(
|
||||
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
|
||||
);
|
||||
const latest = age.checks[0];
|
||||
const pending = latest?.status === "pending" ? latest : undefined;
|
||||
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Age Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
{verified ? (
|
||||
<p className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="adult">Age verified</Tag>
|
||||
<span className="text-muted-foreground">
|
||||
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
|
||||
IMVU accounts count as adult.
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{pending ? (
|
||||
<p>
|
||||
{back
|
||||
? "Thanks. Your check is being processed; this page shows the result when it's done."
|
||||
: "Your check isn't finished yet."}
|
||||
</p>
|
||||
) : latest?.status === "complete" && !latest.overEighteen ? (
|
||||
<p>Your last check found you're under 18.</p>
|
||||
) : latest?.status === "failed" || latest?.status === "revoked" ? (
|
||||
<p>Your last check didn't pass. You can try again.</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
|
||||
</p>
|
||||
)}
|
||||
{ways.map((p) => (
|
||||
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
|
||||
<div className="space-y-2">
|
||||
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
|
||||
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
|
||||
</SubmitButton>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Done by {p.vendor}. T5DE only keeps whether you're over 18, never your ID, photo or birth date.
|
||||
</p>
|
||||
</div>
|
||||
</ActionForm>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,17 +8,24 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, getMe, isModerator, unwrap } from "@/lib/api";
|
||||
import { linkAccount, signOutComputers } from "./actions";
|
||||
import { AgeCard } from "./age-card";
|
||||
import { ChallengeCard } from "./challenge-card";
|
||||
import { LinkCard } from "./link-card";
|
||||
import { TokensCard } from "./tokens-card";
|
||||
|
||||
export const metadata: Metadata = { title: "Account" };
|
||||
|
||||
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) {
|
||||
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/account");
|
||||
const account = unwrap(await api.account.get());
|
||||
const [account, age, tokens] = await Promise.all([
|
||||
api.account.get().then(unwrap),
|
||||
api.age.get().then(unwrap),
|
||||
api.account.tokens.get().then(unwrap),
|
||||
]);
|
||||
const { person, links, challenges, computers } = account;
|
||||
const welcome = (await searchParams).welcome !== undefined;
|
||||
const params = await searchParams;
|
||||
const welcome = params.welcome !== undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -78,12 +85,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
|
||||
can see adult community products; each account is checked on its own.
|
||||
Other people can't see which IMVU accounts are yours. Adult IMVU accounts can see adult community
|
||||
products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AgeCard age={age} back={params.age === "back"} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>T5DE on your computers</CardTitle>
|
||||
@@ -112,6 +121,8 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TokensCard data={tokens} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
// Making an API token: a name, how long it lasts, and what it may do where (some of the person's own permissions).
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { ErrorText } from "@/components/forms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import type { TokenScope } from "@/lib/api";
|
||||
import { createToken } from "./actions";
|
||||
|
||||
const keyOf = (s: TokenScope["scope"]) => (s.type === "hub" ? "hub" : `${s.type}:${s.id}`);
|
||||
const kindOf = { hub: "Everything", catalog: "Catalog", product: "Product" } as const;
|
||||
|
||||
type Row = { scope: string; permissions: string[] };
|
||||
|
||||
// Permissions are `resource:action`; they're grouped by resource, in this order (new resources go last, by name).
|
||||
const groupNames: Record<string, string> = {
|
||||
product: "Products",
|
||||
revision: "Revisions",
|
||||
catalog: "Catalog",
|
||||
team: "Team",
|
||||
member: "Members",
|
||||
hosting: "Hosting",
|
||||
import: "Imports",
|
||||
case: "Cases",
|
||||
audit: "Audit Log",
|
||||
stats: "Stats",
|
||||
person: "People",
|
||||
room: "Rooms",
|
||||
settings: "Settings",
|
||||
};
|
||||
const order = Object.keys(groupNames);
|
||||
|
||||
/** A scope's permissions, grouped by resource: [group name, permissions]. */
|
||||
function groupsOf(permissions: string[]): [string, string[]][] {
|
||||
const by = new Map<string, string[]>();
|
||||
for (const p of permissions) {
|
||||
const resource = p.split(":")[0]!;
|
||||
by.set(resource, [...(by.get(resource) ?? []), p]);
|
||||
}
|
||||
const rank = (r: string) => (order.includes(r) ? order.indexOf(r) : order.length);
|
||||
return [...by.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([r, list]) => [groupNames[r] ?? r.charAt(0).toUpperCase() + r.slice(1), list]);
|
||||
}
|
||||
|
||||
export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: number }) {
|
||||
const [state, formAction, pending] = useActionState(createToken, null);
|
||||
const [rows, setRows] = useState<Row[]>([{ scope: keyOf(scopes[0]!.scope), permissions: [] }]);
|
||||
const byKey = new Map(scopes.map((s) => [keyOf(s.scope), s]));
|
||||
const grants = rows.flatMap((r) => r.permissions.map((permission) => ({ permission, scope: byKey.get(r.scope)!.scope })));
|
||||
const set = (i: number, row: Row) => setRows(rows.map((r, j) => (j === i ? row : r)));
|
||||
const unused = scopes.find((s) => !rows.some((r) => r.scope === keyOf(s.scope)));
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<input type="hidden" name="grants" value={JSON.stringify(grants)} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input name="name" required maxLength={64} placeholder="Name, e.g. Upload script" aria-label="Name" className="max-w-xs" />
|
||||
<NativeSelect name="days" defaultValue="90" aria-label="Lasts" className="w-36">
|
||||
{[7, 30, 90, maxDays].map((d) => (
|
||||
<NativeSelectOption key={d} value={d}>
|
||||
{d === 365 ? "1 year" : `${d} days`}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
{rows.map((row, i) => {
|
||||
const scope = byKey.get(row.scope)!;
|
||||
return (
|
||||
<fieldset key={i} className="space-y-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
value={row.scope}
|
||||
onChange={(e) => set(i, { scope: e.target.value, permissions: [] })}
|
||||
aria-label="Where"
|
||||
className="min-w-56"
|
||||
>
|
||||
{scopes.map((s) => {
|
||||
const k = keyOf(s.scope);
|
||||
if (k !== row.scope && rows.some((r) => r.scope === k)) return null;
|
||||
return (
|
||||
<NativeSelectOption key={k} value={k}>
|
||||
{s.scope.type === "hub" ? "Everything on T5DE" : `${kindOf[s.scope.type]}: ${s.name}`}
|
||||
</NativeSelectOption>
|
||||
);
|
||||
})}
|
||||
</NativeSelect>
|
||||
{rows.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y rounded-md border">
|
||||
{groupsOf(scope.permissions).map(([group, list]) => {
|
||||
const chosen = list.filter((p) => row.permissions.includes(p)).length;
|
||||
const toggleAll = (on: boolean) =>
|
||||
set(i, { ...row, permissions: on ? [...new Set([...row.permissions, ...list])] : row.permissions.filter((x) => !list.includes(x)) });
|
||||
return (
|
||||
<details key={group} className="group/perm">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm select-none [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-open/perm:rotate-90" aria-hidden />
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`All ${group}`}
|
||||
checked={chosen === list.length}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = chosen > 0 && chosen < list.length;
|
||||
}}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">{group}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chosen > 0 ? `${chosen} of ${list.length}` : list.length === 1 ? "1 permission" : `${list.length} permissions`}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="grid gap-x-4 gap-y-1 px-3 pt-1 pb-3 pl-10 sm:grid-cols-2">
|
||||
{list.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 font-mono text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.permissions.includes(p)}
|
||||
onChange={(e) =>
|
||||
set(i, { ...row, permissions: e.target.checked ? [...row.permissions, p] : row.permissions.filter((x) => x !== p) })
|
||||
}
|
||||
/>
|
||||
{p.slice(p.indexOf(":") + 1)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unused && (
|
||||
<Button type="button" variant="outline" onClick={() => setRows([...rows, { scope: keyOf(unused.scope), permissions: [] }])}>
|
||||
Add another place
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={pending || grants.length === 0}>
|
||||
{pending ? "Making…" : "Make token"}
|
||||
</Button>
|
||||
</div>
|
||||
{state?.error && <ErrorText>{state.error}</ErrorText>}
|
||||
{state?.token && (
|
||||
<div role="status" className="space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p>
|
||||
Your token <strong>{state.name}</strong>. Copy it now: it won't be shown again.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 rounded bg-muted px-2 py-1 font-mono text-xs break-all">{state.token}</code>
|
||||
<CopyButton text={state.token} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Send it as <code className="font-mono text-xs">Authorization: Bearer <token></code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// API tokens on the account page: the person's tokens, and making one.
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TokenEntry, TokenList } from "@/lib/api";
|
||||
import { revokeToken } from "./actions";
|
||||
import { TokenForm } from "./token-form";
|
||||
|
||||
export function TokensCard({ data }: { data: TokenList }) {
|
||||
const active = data.tokens.filter((t) => t.status === "active");
|
||||
const past = data.tokens.filter((t) => t.status !== "active").slice(0, 10);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Tokens</CardTitle>
|
||||
<CardDescription>
|
||||
For your own scripts. A token can do only what you give it, and never more than you can do yourself.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{active.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{active.map((t) => (
|
||||
<TokenRow key={t.id} token={t} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.scopes.length > 0 ? (
|
||||
<TokenForm scopes={data.scopes} maxDays={data.maxDays} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tokens act on what you manage: your products or a catalog's team. You don't manage anything yet.
|
||||
</p>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Revoked and expired</summary>
|
||||
<ul className="mt-2 divide-y">
|
||||
{past.map((t) => (
|
||||
<TokenRow key={t.id} token={t} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({ token: t }: { token: TokenEntry }) {
|
||||
return (
|
||||
<li className="space-y-1 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.status !== "active" && <Tag tone={t.status === "revoked" ? "danger" : "neutral"}>{t.status}</Tag>}
|
||||
<span className="flex-1" />
|
||||
{t.status === "active" && (
|
||||
<ActionForm action={revokeToken} fields={{ id: t.id }}>
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…" confirm={`Revoke ${t.name}? Scripts using it stop working.`}>
|
||||
Revoke
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.grants.map((g, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ", "}
|
||||
<span className="font-mono">{g.permission}</span> on {g.scope.type === "hub" ? "everything" : g.name}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Made <DateTime iso={t.createdAt} dateOnly />
|
||||
{" · "}
|
||||
{t.status === "revoked" && t.revokedAt ? (
|
||||
<>
|
||||
revoked <DateTime iso={t.revokedAt} dateOnly />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t.status === "expired" ? "expired" : "expires"} <DateTime iso={t.expiresAt} dateOnly />
|
||||
</>
|
||||
)}
|
||||
{" · "}
|
||||
{t.lastUsedAt ? (
|
||||
<>
|
||||
last used <DateTime iso={t.lastUsedAt} />
|
||||
</>
|
||||
) : (
|
||||
"never used"
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ReportForm } from "@/components/cases";
|
||||
import { membership as membershipLabel } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { membership } from "../../console/catalogs/actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalog" };
|
||||
|
||||
export default async function CatalogPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ invite?: string | string[] }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const raw = (await searchParams).invite;
|
||||
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
|
||||
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
|
||||
if (answer.error) notFound();
|
||||
const data = unwrap(answer);
|
||||
const c = data.catalog;
|
||||
const manage = data.permissions.includes("audit:read");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={c.name}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span>{membershipLabel[c.membership] ?? c.membership}</span>
|
||||
{c.maxRating === "general" && <Tag>General audiences</Tag>}
|
||||
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
|
||||
{data.membership === "requested" && <Tag>Asked to join</Tag>}
|
||||
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Products</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Takedown Contact</CardDescription>
|
||||
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Membership</CardTitle>
|
||||
<CardDescription>
|
||||
{c.membership === "open"
|
||||
? "Anyone on T5DE can get its products."
|
||||
: c.membership === "approval"
|
||||
? "Ask to join; its team decides. Anyone can see its products on others."
|
||||
: "Join with an invitation. Anyone can see its products on others."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!me ? (
|
||||
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
|
||||
Sign in to join
|
||||
</Link>
|
||||
) : data.membership === "banned" ? (
|
||||
<p className="text-sm text-muted-foreground">This catalog's team banned you from it.</p>
|
||||
) : data.membership === "member" || data.membership === "requested" ? (
|
||||
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
|
||||
{data.membership === "member" ? "Leave" : "Withdraw request"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
) : c.membership === "open" ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
|
||||
) : c.membership === "invite" && !invite ? (
|
||||
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
|
||||
) : (
|
||||
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
|
||||
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{manage && (
|
||||
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Manage
|
||||
</Link>
|
||||
)}
|
||||
{me && !manage && <ReportForm about="catalog" id={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -71,7 +72,11 @@ function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; s
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={showStatus ? `/console/catalogs/${c.id}` : `/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
|
||||
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
|
||||
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"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";
|
||||
|
||||
@@ -52,3 +53,25 @@ export async function decideCatalog(_: ActionState, form: FormData): Promise<Act
|
||||
revalidatePath("/catalogs");
|
||||
});
|
||||
}
|
||||
|
||||
/** An account waiting for approval: approve it, or decline it (deleting it). */
|
||||
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const decline = field(form, "decision") === "decline";
|
||||
const state = await attempt(async () => {
|
||||
if (decline) unwrap(await api.console.people({ id }).decline.post());
|
||||
else unwrap(await api.console.people({ id }).approve.post());
|
||||
revalidatePath("/console", "layout");
|
||||
});
|
||||
if (decline && !state?.error) redirect("/console/people?waiting=true");
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
const registration = field(form, "registration") === "closed" ? "closed" : "open";
|
||||
unwrap(await api.console.settings.put({ registration }));
|
||||
revalidatePath("/console");
|
||||
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AuditEntry } from "@/lib/api";
|
||||
import { appeal } from "./cases/actions";
|
||||
|
||||
const tagLinks: Record<string, (id: string) => string> = {
|
||||
catalog: (id) => `/console/catalogs/${id}`,
|
||||
product: (id) => `/products/${id}`,
|
||||
room: (id) => `/rooms/${id}/edit`,
|
||||
};
|
||||
|
||||
/** Audit entries: when, who, what, which resources (linked), and the details. */
|
||||
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>About</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
|
||||
{e.actor}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">hub</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell className="space-x-2 text-sm">
|
||||
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
|
||||
if (!id) return null;
|
||||
const name = (e.tagNames as Record<string, string>)[id] ?? id;
|
||||
const href = tagLinks[kind]?.(id);
|
||||
return (
|
||||
<span key={kind}>
|
||||
<span className="text-muted-foreground">{kind} </span>
|
||||
{href ? (
|
||||
<Link href={href} className="underline-offset-4 hover:underline">
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{Object.keys(e.tags).length === 0 &&
|
||||
(e.action.startsWith("file.") && /^[0-9a-f]{64}$/.test(e.target) ? (
|
||||
<Link href={`/console/blocks/${e.target}`} className="font-mono text-xs underline-offset-4 hover:underline">
|
||||
file {e.target.slice(0, 12)}…
|
||||
</Link>
|
||||
) : (
|
||||
e.target
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||
{detailText(e.detail)}
|
||||
{e.appealable && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-foreground underline-offset-4 hover:underline">Appeal</summary>
|
||||
<ActionForm action={appeal} fields={{ audit: e.id }} className="mt-2">
|
||||
<div className="flex gap-2">
|
||||
<Input name="reason" required maxLength={2000} placeholder="Why it should be undone" aria-label="Why" className="h-8 min-w-48" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Sending…">
|
||||
Send
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</details>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function detailText(detail: unknown): string {
|
||||
return Object.entries((detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => {
|
||||
if (k === "changed" && v && typeof v === "object")
|
||||
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
|
||||
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
|
||||
.join(", ");
|
||||
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { AuditTable } from "../audit-table";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit Log" };
|
||||
|
||||
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
|
||||
type Filters = Partial<Record<(typeof filters)[number], string>>;
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
const { console: data } = await requireConsole("/console/audit");
|
||||
const params = await searchParams;
|
||||
const f: Filters = {};
|
||||
for (const k of filters) {
|
||||
const v = params[k];
|
||||
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
|
||||
}
|
||||
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
|
||||
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
|
||||
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
|
||||
const moderator = data.moderation !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit Log">
|
||||
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
|
||||
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
|
||||
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
|
||||
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
|
||||
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
|
||||
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
|
||||
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
|
||||
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">Filter</Button>
|
||||
{Object.keys(f).length > 0 && (
|
||||
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
|
||||
Clear
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{next && entries.length === 100 && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { ConsoleNav, requireConsole } from "../../console-nav";
|
||||
import { blockFile, unblockFile } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Block" };
|
||||
|
||||
/** A file: whether it's blocked, the products using it, and blocking or unblocking it. */
|
||||
export default async function BlockPage({ params }: { params: Promise<{ sha256: string }> }) {
|
||||
const { sha256 } = await params;
|
||||
if (!/^[0-9a-f]{64}$/.test(sha256)) notFound();
|
||||
const { console: c } = await requireConsole(`/console/blocks/${sha256}`);
|
||||
if (!c.sections.includes("blocks")) notFound();
|
||||
const { block, products } = unwrap(await api.blocks({ sha256 }).get());
|
||||
const live = products.filter((p) => !p.removed);
|
||||
const removeCount = live.length === 1 ? "1 product" : `${live.length} products`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="File">
|
||||
<span className="font-mono text-xs break-all">{sha256}</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{block ? "Blocked" : "Not Blocked"}</CardTitle>
|
||||
<CardDescription>
|
||||
{block ? (
|
||||
<>
|
||||
By {block.by ?? "a former moderator"}, <DateTime iso={block.at} />: {block.reason}
|
||||
</>
|
||||
) : (
|
||||
"Blocking it removes the products below, tells their catalogs to delete it, and refuses uploads with it."
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{block ? (
|
||||
<ActionForm action={unblockFile} fields={{ sha256 }}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input name="reason" maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
||||
<SubmitButton
|
||||
variant="outline"
|
||||
pendingLabel="Unblocking…"
|
||||
confirm="Unblock this file? The products it removed stay removed until restored."
|
||||
>
|
||||
Unblock
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
) : (
|
||||
<ActionForm action={blockFile} fields={{ sha256 }}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input name="reason" required maxLength={500} placeholder="Reason (creators see it)" aria-label="Reason" className="max-w-sm" />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
pendingLabel="Blocking…"
|
||||
confirm={live.length ? `Block this file and remove ${removeCount}?` : "Block this file?"}
|
||||
>
|
||||
{live.length ? `Block and remove ${removeCount}` : "Block"}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Used By</CardTitle>
|
||||
<CardDescription>Products with a revision that has this file.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No product uses it.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{products.map((p) => (
|
||||
<li key={p.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0">
|
||||
<Link
|
||||
href={p.removed ? `/console/products?q=${encodeURIComponent(p.name)}&status=removed&all=1` : `/products/${p.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
by {p.creatorUsername ?? "someone"} · {p.revisions.length === 1 ? "revision" : "revisions"} {p.revisions.join(", ")}
|
||||
</span>
|
||||
{p.removed && <Tag tone="danger">removed</Tag>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
// File blocks, from the console.
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const done = (sha256: string) => {
|
||||
revalidatePath("/console/blocks");
|
||||
revalidatePath(`/console/blocks/${sha256}`);
|
||||
revalidatePath("/console/products");
|
||||
};
|
||||
|
||||
/** Blocks a file network-wide, removing the products using it. */
|
||||
export async function blockFile(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const sha256 = field(form, "sha256");
|
||||
return attempt(async () => {
|
||||
const { removed } = unwrap(await api.blocks({ sha256 }).put({ reason: field(form, "reason") }));
|
||||
done(sha256);
|
||||
return removed.length ? `Blocked. Removed ${removed.length === 1 ? "1 product" : `${removed.length} products`}.` : "Blocked.";
|
||||
});
|
||||
}
|
||||
|
||||
/** Unblocks a file. The products it removed stay removed. */
|
||||
export async function unblockFile(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const sha256 = field(form, "sha256");
|
||||
return attempt(async () => {
|
||||
unwrap(await api.blocks({ sha256 }).delete({ reason: field(form, "reason") }));
|
||||
done(sha256);
|
||||
return "Unblocked.";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Blocks" };
|
||||
|
||||
export default async function BlocksPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
const { console: c } = await requireConsole("/console/blocks");
|
||||
if (!c.sections.includes("blocks")) notFound();
|
||||
const params = await searchParams;
|
||||
// Looking a file up goes to its page.
|
||||
if (typeof params.sha256 === "string") {
|
||||
const sha256 = params.sha256.trim().toLowerCase();
|
||||
if (/^[0-9a-f]{64}$/.test(sha256)) redirect(`/console/blocks/${sha256}`);
|
||||
}
|
||||
const before = typeof params.before === "string" && !Number.isNaN(Date.parse(params.before)) ? params.before : undefined;
|
||||
const data = unwrap(await api.blocks.get({ query: before ? { before } : {} }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Blocks">Blocked files: uploads with them are refused, and catalogs delete them.</PageHeader>
|
||||
<ConsoleNav />
|
||||
<form action="/console/blocks" className="mb-6 flex flex-wrap gap-2">
|
||||
<Input
|
||||
name="sha256"
|
||||
required
|
||||
pattern="\s*[0-9a-fA-F]{64}\s*"
|
||||
defaultValue={typeof params.sha256 === "string" ? params.sha256 : ""}
|
||||
placeholder="A file's sha256"
|
||||
aria-label="sha256"
|
||||
className="max-w-xl font-mono"
|
||||
/>
|
||||
<Button type="submit">Look up</Button>
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{data.blocks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing blocked. Block a file from a product's files, or look it up above.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>By</TableHead>
|
||||
<TableHead>When</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.blocks.map((b) => (
|
||||
<TableRow key={b.sha256}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link href={`/console/blocks/${b.sha256}`} className="underline-offset-4 hover:underline">
|
||||
{b.sha256.slice(0, 16)}…
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal">{b.reason}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{b.by ?? ""}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={b.at} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.before && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/console/blocks?before=${encodeURIComponent(data.before)}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CaseAbout, caseKinds, CaseStatus, resolveLabel } from "@/components/cases";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { ConsoleNav, requireConsole } from "../../console-nav";
|
||||
import { addNote, assign, decide } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Case" };
|
||||
|
||||
/** What each kind of case asks. */
|
||||
const asks: Record<string, string> = {
|
||||
report: "Someone reported this.",
|
||||
"rating-review": "Its creator asks to lower its rating from adult to general.",
|
||||
"removal-request": "A catalog hosting it asks for it to be removed everywhere.",
|
||||
"catalog-deletion": "Its owner asks for it to be deleted.",
|
||||
appeal: "The person it affected asks for this action to be undone.",
|
||||
};
|
||||
|
||||
export default async function CasePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!/^\d{1,9}$/.test(id)) notFound();
|
||||
const { me } = await requireConsole(`/console/cases/${id}`);
|
||||
const answer = await api.cases({ id: Number(id) }).get();
|
||||
if (answer.error) notFound();
|
||||
const { case: c, notes, canDecide, canAssign } = unwrap(answer);
|
||||
const appealed = c.kind === "appeal" ? (c.data as { action?: string; detail?: Record<string, unknown> }) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={`${caseKinds[c.kind] ?? c.kind} #${c.id}`}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<CaseStatus status={c.status} />
|
||||
<span>
|
||||
opened by {c.openedBy || "someone"} <DateTime iso={c.createdAt} />
|
||||
</span>
|
||||
{c.assignee && <Tag>assigned to {c.assignee}</Tag>}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About</CardTitle>
|
||||
<CardDescription>{asks[c.kind]}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<p>
|
||||
<CaseAbout tags={c.tags} names={c.tagNames} />
|
||||
</p>
|
||||
{appealed && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Action appealed: </span>
|
||||
<span className="font-mono text-xs">{appealed.action}</span>
|
||||
{appealed.detail && Object.keys(appealed.detail).length > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({Object.entries(appealed.detail)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join(", ")}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{c.reason && <p className="whitespace-pre-line">{c.reason}</p>}
|
||||
{c.status !== "open" && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="font-medium">
|
||||
{c.status === "resolved" ? "Resolved" : "Declined"} by {c.decidedBy || "a moderator"}{" "}
|
||||
{c.decidedAt && (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
<DateTime iso={c.decidedAt} />
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{c.resolution && <p className="mt-1 whitespace-pre-line">{c.resolution}</p>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canDecide && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Decide</CardTitle>
|
||||
<CardDescription>
|
||||
{c.kind === "report" || c.kind === "appeal"
|
||||
? "Resolving records the decision; take any action where it's done (removed products are restored from Console › Products, edits undone from the product's history)."
|
||||
: `Resolving does it: ${resolveLabel[c.kind]?.toLowerCase()}.`}{" "}
|
||||
{c.openedBy ? `${c.openedBy} is told, with what you write.` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={decide} fields={{ case: c.id }}>
|
||||
<Textarea name="resolution" maxLength={2000} rows={3} placeholder="What you decided, and why" aria-label="Resolution" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SubmitButton
|
||||
name="decision"
|
||||
value="resolve"
|
||||
pendingLabel="Saving…"
|
||||
confirm={c.kind === "catalog-deletion" || c.kind === "removal-request" ? `${resolveLabel[c.kind]}?` : undefined}
|
||||
>
|
||||
{resolveLabel[c.kind] ?? "Resolve"}
|
||||
</SubmitButton>
|
||||
<SubmitButton name="decision" value="decline" variant="outline" pendingLabel="Saving…">
|
||||
Decline
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canAssign && c.status === "open" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assignment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<ActionForm action={assign} fields={{ case: c.id }} className="flex flex-wrap gap-2 space-y-0">
|
||||
<Input name="to" defaultValue={c.assignee ?? me.username} maxLength={64} aria-label="Moderator" className="max-w-48" />
|
||||
<SubmitButton variant="outline" pendingLabel="Saving…">
|
||||
Assign
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
{c.assignee && (
|
||||
<ActionForm action={assign} fields={{ case: c.id, to: "" }}>
|
||||
<SubmitButton variant="ghost" pendingLabel="Saving…">
|
||||
Unassign
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thread</CardTitle>
|
||||
{canDecide && <CardDescription>Internal notes are only for those deciding.</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No notes yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id} className="space-y-1 py-3 first:pt-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{n.by || "Someone"}</span>
|
||||
{n.internal && <Tag>Internal</Tag>}
|
||||
<span className="text-muted-foreground">
|
||||
<DateTime iso={n.at} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-line">{n.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<ActionForm action={addNote} fields={{ case: c.id }}>
|
||||
<Textarea name="body" required maxLength={4000} rows={3} placeholder="Write a note" aria-label="Note" />
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<SubmitButton variant="outline" pendingLabel="Sending…">
|
||||
Add note
|
||||
</SubmitButton>
|
||||
{canDecide && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="internal" className="size-4 accent-foreground" />
|
||||
Internal
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link href="/console/cases" className="text-sm underline-offset-4 hover:underline">
|
||||
All cases
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use server";
|
||||
// Cases (reports, reviews, requests, appeals) and notifications, from the website.
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
/** Reports a product, catalog, room or person. */
|
||||
export async function report(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const kind = field(form, "about");
|
||||
const id = field(form, "id");
|
||||
if (!["product", "catalog", "room", "person"].includes(kind)) return { error: "Nothing to report." };
|
||||
return attempt(async () => {
|
||||
unwrap(await api.cases.post({ kind: "report", about: { [kind]: id }, reason: field(form, "reason") }));
|
||||
revalidatePath("/console/cases");
|
||||
return "Reported. Moderators will look at it; you'll be told what they decide.";
|
||||
});
|
||||
}
|
||||
|
||||
/** Appeals an action from the audit log. */
|
||||
export async function appeal(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
let id = 0;
|
||||
const state = await attempt(async () => {
|
||||
const { case: c } = unwrap(await api.cases.post({ kind: "appeal", audit: Number(field(form, "audit")), reason: field(form, "reason") }));
|
||||
id = c.id;
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect(`/console/cases/${id}`);
|
||||
}
|
||||
|
||||
/** A catalog's team asks moderators to remove a product everywhere. */
|
||||
export async function requestRemoval(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(
|
||||
await api.cases.post({
|
||||
kind: "removal-request",
|
||||
about: { product: field(form, "product") },
|
||||
catalog: field(form, "catalog"),
|
||||
reason: field(form, "reason"),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/console/cases");
|
||||
return "Asked. Moderators will decide.";
|
||||
});
|
||||
}
|
||||
|
||||
/** A catalog's owner asks for it to be deleted. */
|
||||
export async function requestDeletion(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
let kase = 0;
|
||||
const state = await attempt(async () => {
|
||||
kase = unwrap(await api.catalogs({ id }).delete.post({ reason: field(form, "reason") })).case;
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect(`/console/cases/${kase}`);
|
||||
}
|
||||
|
||||
const onCase = (form: FormData) => {
|
||||
const id = Number(field(form, "case"));
|
||||
return { id, done: () => revalidatePath(`/console/cases/${id}`) };
|
||||
};
|
||||
|
||||
export async function addNote(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const { id, done } = onCase(form);
|
||||
return attempt(async () => {
|
||||
unwrap(await api.cases({ id }).notes.post({ body: field(form, "body"), internal: form.get("internal") === "on" }));
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
export async function assign(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const { id, done } = onCase(form);
|
||||
return attempt(async () => {
|
||||
const to = field(form, "to");
|
||||
unwrap(await api.cases({ id }).assign.post({ to: to || null }));
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
export async function decide(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const { id, done } = onCase(form);
|
||||
const decision = field(form, "decision");
|
||||
if (decision !== "resolve" && decision !== "decline") return { error: "Choose to resolve or decline it." };
|
||||
return attempt(async () => {
|
||||
unwrap(await api.cases({ id }).decide.post({ decision, resolution: field(form, "resolution") }));
|
||||
done();
|
||||
revalidatePath("/console/cases");
|
||||
revalidatePath("/console");
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAllRead(_: ActionState): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(await api.notifications.read.post({}));
|
||||
revalidatePath("/notifications");
|
||||
revalidatePath("/", "layout");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CaseAbout, caseKinds, CaseStatus } from "@/components/cases";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Cases" };
|
||||
|
||||
const statuses = ["open", "resolved", "declined"] as const;
|
||||
const kinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
|
||||
|
||||
export default async function CasesPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
await requireConsole("/console/cases");
|
||||
const params = await searchParams;
|
||||
// Open cases unless asked otherwise ("any" for all).
|
||||
const status = params.status === "any" ? undefined : (statuses.find((s) => s === params.status) ?? "open");
|
||||
const kind = kinds.find((k) => k === params.kind);
|
||||
const mine = params.mine === "1";
|
||||
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
|
||||
const data = unwrap(
|
||||
await api.cases.get({
|
||||
query: {
|
||||
...(status ? { status } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
...(mine ? { mine: "1" as const } : {}),
|
||||
...(before ? { before } : {}),
|
||||
limit: 50,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const older = new URLSearchParams({
|
||||
status: status ?? "any",
|
||||
...(kind ? { kind } : {}),
|
||||
...(mine ? { mine: "1" } : {}),
|
||||
...(data.before ? { before: String(data.before) } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Cases">
|
||||
{data.moderator ? "Reports, reviews, requests and appeals." : "Cases you opened, and those about what you manage."}
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<form action="/console/cases" className="mb-6 flex flex-wrap gap-2">
|
||||
<NativeSelect name="status" defaultValue={status ?? "any"} aria-label="Status" className="w-36">
|
||||
<NativeSelectOption value="open">Open</NativeSelectOption>
|
||||
<NativeSelectOption value="resolved">Resolved</NativeSelectOption>
|
||||
<NativeSelectOption value="declined">Declined</NativeSelectOption>
|
||||
<NativeSelectOption value="any">Any status</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<NativeSelect name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="w-44">
|
||||
<NativeSelectOption value="">Any kind</NativeSelectOption>
|
||||
{kinds.map((k) => (
|
||||
<NativeSelectOption key={k} value={k}>
|
||||
{caseKinds[k]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<NativeSelect name="mine" defaultValue={mine ? "1" : ""} aria-label="Whose" className="w-44">
|
||||
<NativeSelectOption value="">{data.moderator ? "Everyone's" : "All I can see"}</NativeSelectOption>
|
||||
<NativeSelectOption value="1">Opened by me</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Button type="submit">Filter</Button>
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{data.cases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing here.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Case</TableHead>
|
||||
<TableHead>About</TableHead>
|
||||
<TableHead>Opened by</TableHead>
|
||||
<TableHead>Assigned</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Changed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.cases.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/console/cases/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{caseKinds[c.kind] ?? c.kind} #{c.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<CaseAbout tags={c.tags} names={c.tagNames} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.openedBy || "someone"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.assignee ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<CaseStatus status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={c.updatedAt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.before && data.cases.length === 50 && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/console/cases?${older}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CatalogStatus } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, RatingTag } from "@/components/products";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
|
||||
import { AuditTable } from "../../audit-table";
|
||||
import { requestDeletion, requestRemoval } from "../../cases/actions";
|
||||
import { ConsoleNav, requireConsole } from "../../console-nav";
|
||||
import {
|
||||
addOperator,
|
||||
createInvite,
|
||||
decideMember,
|
||||
delist,
|
||||
removeFromTeam,
|
||||
revokeInvite,
|
||||
rotateKey,
|
||||
saveCatalog,
|
||||
setSuspended,
|
||||
} from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalog" };
|
||||
|
||||
type Perm = CatalogPage["permissions"][number];
|
||||
|
||||
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { me } = await requireConsole(`/console/catalogs/${id}`);
|
||||
const answer = await api.catalogs({ id }).get();
|
||||
if (answer.error) notFound();
|
||||
const data = unwrap(answer);
|
||||
const has = (p: Perm) => data.permissions.includes(p);
|
||||
if (!has("audit:read")) notFound();
|
||||
const c = data.catalog;
|
||||
|
||||
const [team, members, invites, hosted, audit] = await Promise.all([
|
||||
api.catalogs({ id }).team.get().then(unwrap),
|
||||
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
|
||||
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
|
||||
api.catalogs({ id }).products.get().then(unwrap),
|
||||
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
|
||||
]);
|
||||
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
|
||||
const onTeam = team.roles.some((r) => r.personId === me.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={c.name}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<CatalogStatus status={c.status} />
|
||||
<span className="font-mono text-xs">{c.url}</span>
|
||||
{c.status === "approved" && (
|
||||
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
|
||||
Public page
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
{(c.status === "suspended" || c.status === "rejected") && (
|
||||
<Alert>
|
||||
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{c.statusReason || "No reason given."}{" "}
|
||||
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
|
||||
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
|
||||
{data.members && (
|
||||
<>
|
||||
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
|
||||
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveCatalog} fields={{ id }}>
|
||||
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="url">Address</FieldLabel>
|
||||
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
|
||||
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
|
||||
</Field>
|
||||
<Choice
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
value={c.visibility}
|
||||
options={[
|
||||
["public", "Public (listed)"],
|
||||
["private", "Private (by link)"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="membership"
|
||||
label="Membership"
|
||||
value={c.membership}
|
||||
options={[
|
||||
["open", "Open"],
|
||||
["approval", "By approval"],
|
||||
["invite", "Invite only"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="maxRating"
|
||||
label="Highest rating"
|
||||
value={c.maxRating}
|
||||
options={[
|
||||
["general", "General only"],
|
||||
["adult", "Adult too"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="uploads"
|
||||
label="Who may upload"
|
||||
value={c.uploads}
|
||||
options={[
|
||||
["anyone", "Anyone"],
|
||||
["members", "Members"],
|
||||
["team", "Only the team"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="mirrors"
|
||||
label="Mirroring others' products"
|
||||
value={c.mirrors}
|
||||
options={[
|
||||
["auto", "Accept"],
|
||||
["approve", "Team approves"],
|
||||
["off", "Off"],
|
||||
]}
|
||||
/>
|
||||
</fieldset>
|
||||
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:rotate-key") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key</CardTitle>
|
||||
<CardDescription>
|
||||
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={rotateKey} fields={{ id }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
|
||||
New key
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team</CardTitle>
|
||||
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="divide-y text-sm">
|
||||
{team.owner && (
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span>{team.owner.username}</span>
|
||||
<Tag tone="strong">owner</Tag>
|
||||
</li>
|
||||
)}
|
||||
{team.roles.map((r) => (
|
||||
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>{r.username}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>operator</Tag>
|
||||
{(has("team:manage") || r.personId === me.id) && (
|
||||
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pendingLabel="Removing…"
|
||||
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
|
||||
>
|
||||
{r.personId === me.id ? "Leave" : "Remove"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{has("team:manage") && (
|
||||
<ActionForm action={addOperator} fields={{ id }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
{!onTeam && team.owner?.personId !== me.id && (
|
||||
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{members && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
{c.membership === "open"
|
||||
? "Open: anyone can get its products. Bans still apply."
|
||||
: c.membership === "approval"
|
||||
? "By approval: people ask, the team decides."
|
||||
: "Invite only: people join with an invitation."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
|
||||
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
|
||||
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
|
||||
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton variant="outline" pendingLabel="Adding…">
|
||||
Add member
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invites && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitations</CardTitle>
|
||||
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invites.invites.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Used</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.invites.map((i) => (
|
||||
<TableRow key={i.code}>
|
||||
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{i.uses}
|
||||
{i.maxUses !== null && ` of ${i.maxUses}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
|
||||
Revoke
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<ActionForm action={createInvite} fields={{ id }}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
|
||||
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
|
||||
</Field>
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
|
||||
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
|
||||
</Field>
|
||||
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Products</CardTitle>
|
||||
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hosted.products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Creator</TableHead>
|
||||
<TableHead>Revisions</TableHead>
|
||||
<TableHead>Since</TableHead>
|
||||
{has("hosting:delist") && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hosted.products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
|
||||
{p.removed && <Tag tone="danger">removed</Tag>}
|
||||
{p.deleted && <Tag>deleted</Tag>}
|
||||
</TableCell>
|
||||
<TableCell>{p.creator}</TableCell>
|
||||
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={p.since} dateOnly />
|
||||
</TableCell>
|
||||
{has("hosting:delist") && (
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={delist} fields={{ id, product: p.id }}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
|
||||
Delist
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
{!p.removed && (
|
||||
<details className="mt-1 text-left text-xs">
|
||||
<summary className="cursor-pointer text-right text-muted-foreground hover:text-foreground">
|
||||
Ask moderators to remove it everywhere
|
||||
</summary>
|
||||
<ActionForm action={requestRemoval} fields={{ catalog: id, product: p.id }} className="mt-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} className="h-8 w-48" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Asking…">
|
||||
Ask
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</details>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moderation</CardTitle>
|
||||
<CardDescription>
|
||||
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={setSuspended} fields={{ id }}>
|
||||
{c.status === "suspended" ? (
|
||||
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
|
||||
Reinstate
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<div className="flex max-w-xl gap-2">
|
||||
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
|
||||
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
|
||||
Suspend
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{has("catalog:delete-request") && c.status !== "deleted" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delete Catalog</CardTitle>
|
||||
<CardDescription>
|
||||
Moderators review deletions for now. Once deleted, nothing is available from it; people who got its products
|
||||
keep them only where other catalogs host them.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={requestDeletion} fields={{ id }}>
|
||||
<div className="flex max-w-xl gap-2">
|
||||
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} />
|
||||
<SubmitButton variant="destructive" pendingLabel="Asking…" confirm={`Ask moderators to delete ${c.name}?`}>
|
||||
Ask to delete
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
|
||||
All changes to this catalog
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditTable entries={audit.entries} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
||||
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
|
||||
{options.map(([v, l]) => (
|
||||
<NativeSelectOption key={v} value={v}>
|
||||
{l}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const memberActions = {
|
||||
approve: { label: "Approve", variant: "default" as const },
|
||||
decline: { label: "Decline", variant: "outline" as const },
|
||||
ban: { label: "Ban", variant: "outline" as const },
|
||||
remove: { label: "Remove", variant: "outline" as const },
|
||||
unban: { label: "Unban", variant: "outline" as const },
|
||||
};
|
||||
|
||||
function MemberList({
|
||||
id,
|
||||
title,
|
||||
members,
|
||||
actions,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
members: CatalogMember[];
|
||||
actions: (keyof typeof memberActions)[];
|
||||
}) {
|
||||
if (members.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
|
||||
</h3>
|
||||
<ul className="divide-y text-sm">
|
||||
{members.map((m) => (
|
||||
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>
|
||||
{m.username}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{m.invited ? "invited, " : ""}
|
||||
<DateTime iso={m.since} dateOnly />
|
||||
</span>
|
||||
</span>
|
||||
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
|
||||
<div className="flex gap-2">
|
||||
{actions.map((a) => (
|
||||
<SubmitButton
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={memberActions[a].variant}
|
||||
name="decision"
|
||||
value={a}
|
||||
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
|
||||
>
|
||||
{memberActions[a].label}
|
||||
</SubmitButton>
|
||||
))}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension.
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
/** Runs a change to a catalog, then shows its pages again. */
|
||||
async function onCatalog(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
const notice = await change(id);
|
||||
revalidatePath(`/console/catalogs/${id}`);
|
||||
revalidatePath(`/catalogs/${id}`);
|
||||
revalidatePath("/console/catalogs");
|
||||
revalidatePath("/catalogs");
|
||||
return notice;
|
||||
});
|
||||
}
|
||||
|
||||
const pick = <T extends string>(v: string, options: readonly T[]): T | undefined => options.find((o) => o === v);
|
||||
|
||||
export async function saveCatalog(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(
|
||||
await api.catalogs({ id }).patch({
|
||||
name: field(form, "name"),
|
||||
url: field(form, "url"),
|
||||
contact: field(form, "contact"),
|
||||
visibility: pick(field(form, "visibility"), ["public", "private"] as const),
|
||||
membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const),
|
||||
maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const),
|
||||
uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const),
|
||||
mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const),
|
||||
}),
|
||||
);
|
||||
return "Saved.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateKey(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const { key } = unwrap(await api.catalogs({ id }).key.post());
|
||||
return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSuspended(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post());
|
||||
else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function addOperator(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const username = field(form, "username");
|
||||
unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" }));
|
||||
return `${username} is an operator now.`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromTeam(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** A member or request: approve, decline, add, ban, unban or remove. */
|
||||
export async function decideMember(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const member = api.catalogs({ id }).members({ username: field(form, "username") });
|
||||
switch (field(form, "decision")) {
|
||||
case "approve":
|
||||
case "add":
|
||||
unwrap(await member.put({ status: "member" }));
|
||||
break;
|
||||
case "ban":
|
||||
unwrap(await member.put({ status: "banned" }));
|
||||
break;
|
||||
default: // decline, unban, remove
|
||||
unwrap(await member.delete());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInvite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const maxUses = Number(field(form, "maxUses")) || undefined;
|
||||
const expiresInDays = Number(field(form, "expiresInDays")) || undefined;
|
||||
const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays }));
|
||||
return `Invitation link: ${invite.url}`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeInvite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete());
|
||||
});
|
||||
}
|
||||
|
||||
export async function delist(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const product = field(form, "product");
|
||||
unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } }));
|
||||
revalidatePath(`/products/${product}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */
|
||||
export async function membership(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
if (field(form, "intent") === "leave") {
|
||||
unwrap(await api.catalogs({ id }).join.delete());
|
||||
revalidatePath("/library");
|
||||
return "You left.";
|
||||
}
|
||||
const invite = field(form, "invite");
|
||||
const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {}));
|
||||
revalidatePath("/library");
|
||||
return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide.";
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { decideCatalog } from "../actions";
|
||||
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalogs" };
|
||||
|
||||
export default async function ConsoleCatalogs() {
|
||||
await requireModerator("/console/catalogs");
|
||||
const { catalogs } = unwrap(await api.console.catalogs.get());
|
||||
await requireConsole("/console/catalogs");
|
||||
const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Catalogs" />
|
||||
<PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
|
||||
<ConsoleNav />
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() {
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Takedown contact</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
{moderator && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>{" "}
|
||||
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.url}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
|
||||
</TableCell>
|
||||
@@ -51,14 +55,14 @@ export default async function ConsoleCatalogs() {
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
{moderator && (
|
||||
<TableCell className="text-right">
|
||||
{(c.status === "pending" || c.status === "rejected") && (
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status !== "approved" && (
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
)}
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
@@ -73,7 +77,9 @@ export default async function ConsoleCatalogs() {
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { NavLink } from "@/components/header-client";
|
||||
import { getMe, isModerator, type Person } from "@/lib/api";
|
||||
import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
|
||||
|
||||
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
|
||||
/** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
|
||||
export async function requireModerator(path: string): Promise<Person> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
@@ -10,14 +11,50 @@ export async function requireModerator(path: string): Promise<Person> {
|
||||
return me;
|
||||
}
|
||||
|
||||
export function ConsoleNav() {
|
||||
/** The console as this person sees it (once per request); undefined if it isn't for them. */
|
||||
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
|
||||
const answer = await api.console.get();
|
||||
if (answer.error) return undefined;
|
||||
return unwrap(answer);
|
||||
});
|
||||
|
||||
/**
|
||||
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
|
||||
* is for those controlling a product, catalog or room.
|
||||
*/
|
||||
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
const data = await loadConsole();
|
||||
if (!data) notFound();
|
||||
return { me, console: data };
|
||||
}
|
||||
|
||||
type Section = ConsoleData["sections"][number];
|
||||
|
||||
// Settings are on the overview.
|
||||
const tabs: { section: Section; href: string; label: string }[] = [
|
||||
{ section: "overview", href: "/console", label: "Overview" },
|
||||
{ section: "products", href: "/console/products", label: "Products" },
|
||||
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
|
||||
{ section: "cases", href: "/console/cases", label: "Cases" },
|
||||
{ section: "people", href: "/console/people", label: "People" },
|
||||
{ section: "blocks", href: "/console/blocks", label: "Blocks" },
|
||||
{ section: "audit", href: "/console/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
/** The console's sections this person may see. */
|
||||
export async function ConsoleNav() {
|
||||
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
|
||||
return (
|
||||
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||
<NavLink href="/console" exact>
|
||||
Overview
|
||||
{tabs
|
||||
.filter((t) => sections.has(t.section))
|
||||
.map((t) => (
|
||||
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +1,107 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { OnlineList } from "@/components/online-list";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap, type AuditEntry } from "@/lib/api";
|
||||
import { ConsoleNav, requireModerator } from "./console-nav";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { setRegistration } from "./actions";
|
||||
import { AuditTable } from "./audit-table";
|
||||
import { ConsoleNav, requireConsole } from "./console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Console" };
|
||||
|
||||
type Stat = { label: string; value: number; note: string; href?: string };
|
||||
|
||||
export default async function Console() {
|
||||
await requireModerator("/console");
|
||||
const data = unwrap(await api.console.get());
|
||||
const stats = [
|
||||
{ label: "People", value: data.counts.people, note: "accounts" },
|
||||
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
|
||||
{ label: "Computers", value: data.counts.computers, note: "signed in" },
|
||||
const { me, console: data } = await requireConsole("/console");
|
||||
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
|
||||
|
||||
const todo: Stat[] = [
|
||||
{ label: "Cases", value: data.todo.cases, note: "open, for you to decide", href: "/console/cases" },
|
||||
{ label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
|
||||
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
|
||||
];
|
||||
if (data.moderation) {
|
||||
todo.push(
|
||||
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
|
||||
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
|
||||
);
|
||||
}
|
||||
const mine: Stat[] = [
|
||||
{ label: "Your Products", value: data.mine.products, note: "you created", href: "/console/products" },
|
||||
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
|
||||
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
|
||||
];
|
||||
const hub: Stat[] = data.moderation
|
||||
? [
|
||||
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
|
||||
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
|
||||
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Console">Moderation.</PageHeader>
|
||||
<PageHeader title="Console">What you manage on T5DE.</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
|
||||
<StatGrid stats={mine.filter((s) => s.value > 0)} />
|
||||
{hub.length > 0 && <StatGrid stats={hub} />}
|
||||
|
||||
{data.moderation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Online now</CardTitle>
|
||||
<CardTitle>Online Now</CardTitle>
|
||||
<CardDescription>
|
||||
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
|
||||
the page loaded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OnlineList initial={data.online} moderator />
|
||||
<OnlineList initial={data.moderation.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<Card id="settings">
|
||||
<CardHeader>
|
||||
<CardTitle>Registration</CardTitle>
|
||||
<CardDescription>
|
||||
{settings.registration === "closed"
|
||||
? "Closed: new accounts can sign in, but wait for a moderator's approval."
|
||||
: "Open: new accounts can use the hub at once."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={setRegistration}>
|
||||
<div className="flex gap-2">
|
||||
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
|
||||
<NativeSelectOption value="open">Open</NativeSelectOption>
|
||||
<NativeSelectOption value="closed">Closed</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit log</CardTitle>
|
||||
<CardDescription>The latest 50 changes.</CardDescription>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
|
||||
<Link href="/console/audit" className="underline-offset-4 hover:underline">
|
||||
Full audit log
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditLog entries={data.audit} />
|
||||
<AuditTable entries={data.audit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -61,36 +109,27 @@ export default async function Console() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||
function StatGrid({ stats }: { stats: Stat[] }) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell>{e.target}</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{s.href ? (
|
||||
<Link href={s.href} className="underline-offset-4 hover:underline">
|
||||
{s.note}
|
||||
</Link>
|
||||
) : (
|
||||
s.note
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, ApiError, unwrap } from "@/lib/api";
|
||||
import { ban, setLinkCap, setRole, unban } from "../../actions";
|
||||
import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions";
|
||||
import { ConsoleNav, requireModerator } from "../../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Person" };
|
||||
@@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str
|
||||
joined <DateTime iso={person.createdAt} dateOnly />
|
||||
</span>
|
||||
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
|
||||
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
|
||||
<div className="space-y-6">
|
||||
{!person.approved && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Waiting for Approval</CardTitle>
|
||||
<CardDescription>
|
||||
{person.email}. Until approved, they can only sign in. Declining deletes the account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={decideAccount} fields={{ id: person.id }}>
|
||||
<div className="flex gap-2">
|
||||
<SubmitButton name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
<SubmitButton
|
||||
name="decision"
|
||||
value="decline"
|
||||
variant="outline"
|
||||
pendingLabel="Declining…"
|
||||
confirm={`Decline ${person.username}? Their account is deleted.`}
|
||||
>
|
||||
Decline
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IMVU accounts</CardTitle>
|
||||
|
||||
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "People" };
|
||||
|
||||
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||
const raw = (await searchParams).q;
|
||||
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
|
||||
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
|
||||
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
|
||||
export default async function People({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const q = typeof params.q === "string" ? params.q.slice(0, 64) : "";
|
||||
const waiting = params.waiting === "true";
|
||||
const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString();
|
||||
await requireModerator(`/console/people${query ? `?${query}` : ""}`);
|
||||
const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } }));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
|
||||
<ConsoleNav />
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
<form action="/console/people" className="flex max-w-md gap-2">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<form action="/console/people" className="flex max-w-md flex-1 gap-2">
|
||||
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
|
||||
{waiting && <input type="hidden" name="waiting" value="true" />}
|
||||
<Button type="submit" variant="outline">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<Link
|
||||
href={waiting ? "/console/people" : "/console/people?waiting=true"}
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{waiting ? "Show everyone" : "Waiting for approval"}
|
||||
</Link>
|
||||
</div>
|
||||
{people.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nobody found.</p>
|
||||
<p className="text-sm text-muted-foreground">{waiting ? "Nobody is waiting." : "Nobody found."}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={p.createdAt} dateOnly />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{p.banned ? <Tag tone="danger">banned</Tag> : !p.approved && <Tag tone="strong">waiting</Tag>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { RatingTag, StateTags } from "@/components/products";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { restoreProduct } from "../../products/actions";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Products" };
|
||||
|
||||
const statuses = [
|
||||
["", "Any status"],
|
||||
["draft", "Drafts"],
|
||||
["published", "Published"],
|
||||
["unlisted", "Unlisted"],
|
||||
["locked", "Locked"],
|
||||
["deleted", "Deleted"],
|
||||
["removed", "Removed"],
|
||||
] as const;
|
||||
type Status = Exclude<(typeof statuses)[number][0], "">;
|
||||
|
||||
export default async function ConsoleProducts({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
await requireConsole("/console/products");
|
||||
const params = await searchParams;
|
||||
const q = typeof params.q === "string" ? params.q.trim().slice(0, 100) : "";
|
||||
const status = statuses.find(([s]) => s && s === params.status)?.[0] as Status | undefined;
|
||||
const all = params.all === "1";
|
||||
const data = unwrap(
|
||||
await api.console.products.get({
|
||||
query: { ...(q ? { q } : {}), ...(status ? { status } : {}), ...(all ? { all: "1" as const } : {}) },
|
||||
}),
|
||||
);
|
||||
const filtered = q !== "" || status !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader title="Products">
|
||||
{data.all ? "Everyone's products, drafts and removed ones included." : "Your products, drafts included."}
|
||||
</PageHeader>
|
||||
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
|
||||
New product
|
||||
</Link>
|
||||
</div>
|
||||
<ConsoleNav />
|
||||
<form action="/console/products" className="mb-6 flex flex-wrap gap-2">
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder={data.all ? "Name, ID or creator" : "Name or ID"}
|
||||
aria-label="Search"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<NativeSelect name="status" defaultValue={status ?? ""} aria-label="Status" className="w-40">
|
||||
{statuses.map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{data.moderator && (
|
||||
<NativeSelect name="all" defaultValue={all ? "1" : ""} aria-label="Whose" className="w-40">
|
||||
<NativeSelectOption value="">Yours</NativeSelectOption>
|
||||
<NativeSelectOption value="1">Everyone's</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
)}
|
||||
<Button type="submit">Filter</Button>
|
||||
{(filtered || all) && (
|
||||
<Link href="/console/products" className={buttonVariants({ variant: "outline" })}>
|
||||
Clear
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{data.products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{filtered ? "Nothing matches." : "None yet."}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{data.all && <TableHead>Creator</TableHead>}
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead className="text-right">Libraries</TableHead>
|
||||
<TableHead>Changed</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{data.all && <TableCell className="text-muted-foreground">{p.creator}</TableCell>}
|
||||
<TableCell>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<StateTags product={p} />
|
||||
<RatingTag rating={p.rating} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.libraries}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={p.updatedAt} dateOnly />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!p.removed ? (
|
||||
<Link href={`/products/${p.id}/edit`} className={buttonVariants({ size: "sm", variant: "outline" })}>
|
||||
{p.status === "draft" ? "Review" : "Edit"}
|
||||
</Link>
|
||||
) : (
|
||||
data.moderator && (
|
||||
<ActionForm action={restoreProduct} fields={{ id: p.id }}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Input name="reason" placeholder="Reason" aria-label="Reason" maxLength={500} className="h-8 w-36" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Restoring…" confirm={`Restore ${p.name}?`}>
|
||||
Restore
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 |
+16
-2
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Header } from "@/components/header";
|
||||
import { NarrowCard } from "@/components/narrow-card";
|
||||
import { StagingBanner } from "@/components/staging-banner";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -12,12 +15,23 @@ export const metadata: Metadata = {
|
||||
description: "One account for all your IMVU avatars.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const me = await getMe();
|
||||
return (
|
||||
<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>
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">
|
||||
{me && !me.approved ? (
|
||||
<NarrowCard
|
||||
title="Waiting for Approval"
|
||||
description={`Thanks for signing up, ${me.username}. A moderator will approve your account soon; until then you can't use the hub.`}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
<footer className="border-t">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
|
||||
T5DE is unofficial: not made by or affiliated with IMVU.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { caseKinds } from "@/components/cases";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api, getMe, unwrap, type NotificationEntry } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markAllRead } from "../console/cases/actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
|
||||
export default async function Notifications({ searchParams }: { searchParams: Promise<{ before?: string | string[] }> }) {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/notifications");
|
||||
const raw = (await searchParams).before;
|
||||
const before = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : undefined;
|
||||
const data = unwrap(await api.notifications.get({ query: before ? { before } : {} }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader title="Notifications">
|
||||
{data.unread > 0 ? `${data.unread} unread.` : "You're up to date."}
|
||||
</PageHeader>
|
||||
{data.unread > 0 && (
|
||||
<ActionForm action={markAllRead}>
|
||||
<SubmitButton variant="outline" pendingLabel="Marking…">
|
||||
Mark all as read
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{data.notifications.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{data.notifications.map((n) => {
|
||||
const { text, href } = describe(n);
|
||||
return (
|
||||
<li key={n.id} className={cn("flex flex-wrap items-baseline gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0", n.read && "text-muted-foreground")}>
|
||||
{!n.read && <span className="size-2 shrink-0 self-center rounded-full bg-foreground" aria-label="Unread" />}
|
||||
<span className="min-w-0 flex-1 text-sm">
|
||||
{href ? (
|
||||
<Link href={href} className="underline-offset-4 hover:underline">
|
||||
{text}
|
||||
</Link>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<DateTime iso={n.at} />
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.before && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/notifications?before=${data.before}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A notification in words, and where it leads. */
|
||||
function describe(n: NotificationEntry): { text: string; href?: string } {
|
||||
const d = n.data as Record<string, unknown>;
|
||||
const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : "");
|
||||
const why = s("reason") ? `: “${s("reason")}”` : "";
|
||||
switch (n.type) {
|
||||
case "product_edited": {
|
||||
const what =
|
||||
s("kind") === "lock"
|
||||
? "changed the locks on"
|
||||
: s("kind") === "revert"
|
||||
? "undid a change to"
|
||||
: `changed the ${(d.fields as string[] | undefined)?.join(", ") ?? "info"} of`;
|
||||
return { text: `${s("by") || "A moderator"} ${what} ${s("name")}${why}`, href: `/products/${s("product")}/edit` };
|
||||
}
|
||||
case "product_removed":
|
||||
return { text: `Moderation removed ${s("name")}${why}. You can appeal it from your audit log.`, href: `/console/audit?product=${s("product")}` };
|
||||
case "product_restored":
|
||||
return { text: `Moderation restored ${s("name")}${why}`, href: `/products/${s("product")}` };
|
||||
case "case_decided": {
|
||||
const status = s("status") === "resolved" ? "resolved" : "declined";
|
||||
const res = s("resolution") ? `: “${s("resolution")}”` : "";
|
||||
return { text: `Your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"} was ${status}${res}`, href: `/console/cases/${d.case}` };
|
||||
}
|
||||
case "case_note":
|
||||
return { text: `${s("by") || "Someone"} replied on your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"}`, href: `/console/cases/${d.case}` };
|
||||
case "membership_decided": {
|
||||
const status = s("status");
|
||||
const text =
|
||||
status === "member"
|
||||
? `You're a member of ${s("name")}`
|
||||
: status === "banned"
|
||||
? `${s("name")} banned you`
|
||||
: `${s("name")} declined your request to join`;
|
||||
return { text, href: `/catalogs/${s("catalog")}` };
|
||||
}
|
||||
default:
|
||||
return { text: n.type };
|
||||
}
|
||||
}
|
||||
@@ -2,44 +2,100 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StateTags } from "@/components/products";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UploadCard } from "@/components/upload-card";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { saveProduct } from "../../actions";
|
||||
import { api, getMe, unwrap, type InfoEntry } from "@/lib/api";
|
||||
import { publishProduct, revertInfo, saveLocks, saveProduct } from "../../actions";
|
||||
import { loadProduct } from "../../load";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit product" };
|
||||
|
||||
const fieldNames: Record<string, string> = {
|
||||
name: "Name",
|
||||
description: "Description",
|
||||
rating: "Rating",
|
||||
tags: "Tags",
|
||||
listed: "Listed",
|
||||
category: "Category",
|
||||
locked: "Locks",
|
||||
status: "Status",
|
||||
};
|
||||
|
||||
export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=/products/${id}/edit`);
|
||||
const { product, mine } = await loadProduct(id);
|
||||
if (!mine) notFound();
|
||||
const { product, mine, permissions } = await loadProduct(id);
|
||||
const can = (p: string) => (permissions as string[]).includes(p);
|
||||
if (!can("product:edit")) notFound();
|
||||
if (mine && product.deleted) redirect(`/products/${id}`);
|
||||
const [{ entries }, { choices, parent }] = await Promise.all([
|
||||
api.products({ id }).history.get().then(unwrap),
|
||||
api.products({ id }).categories.get().then(unwrap),
|
||||
]);
|
||||
const path = (c: { name: string }[]) => c.map((x) => x.name).join(" › ");
|
||||
|
||||
const editLocked = can("product:edit-locked");
|
||||
const locked = (f: string) => product.locked.includes(f) && !editLocked;
|
||||
const draft = product.status === "draft";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={`Edit ${product.name}`}>
|
||||
<PageHeader title={draft ? `Review ${product.name}` : `Edit ${product.name}`}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StateTags product={product} />
|
||||
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
|
||||
Back to the product
|
||||
{draft ? "Preview its page" : "Back to the product"}
|
||||
</Link>
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
{draft && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Draft</CardTitle>
|
||||
<CardDescription>
|
||||
Only you and moderators see it. Check its info below, then publish it so people can get it. Drafts not
|
||||
published within 30 days are deleted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{can("product:publish") && (
|
||||
<CardContent>
|
||||
<ActionForm action={publishProduct} fields={{ id }}>
|
||||
<SubmitButton pendingLabel="Publishing…">Publish</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Info</CardTitle>
|
||||
{!mine && <CardDescription>You're editing someone else's product: they see your reason.</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveProduct} fields={{ id }}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={product.name} required maxLength={64} className="max-w-md" />
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={product.name}
|
||||
required
|
||||
maxLength={64}
|
||||
disabled={locked("name")}
|
||||
className="max-w-md"
|
||||
/>
|
||||
{locked("name") && <LockedNote />}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="description">Description</FieldLabel>
|
||||
@@ -49,22 +105,103 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
|
||||
defaultValue={product.description}
|
||||
maxLength={20000}
|
||||
rows={8}
|
||||
disabled={locked("description")}
|
||||
className="font-mono text-xs md:text-xs"
|
||||
/>
|
||||
{locked("description") ? (
|
||||
<LockedNote />
|
||||
) : (
|
||||
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||
{product.rating === "adult" ? (
|
||||
<p className="text-sm">Adult</p>
|
||||
<FieldLabel htmlFor="tags">Tags</FieldLabel>
|
||||
<Input
|
||||
id="tags"
|
||||
name="tags"
|
||||
defaultValue={product.tags.join(", ")}
|
||||
maxLength={300}
|
||||
disabled={locked("tags")}
|
||||
placeholder="formal, wool, winter"
|
||||
className="max-w-md"
|
||||
/>
|
||||
{locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="category">Category</FieldLabel>
|
||||
{locked("category") || choices.length === 0 ? (
|
||||
<p className="text-sm">{product.categories.length ? path(product.categories) : "None known"}</p>
|
||||
) : (
|
||||
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
|
||||
<NativeSelect
|
||||
id="category"
|
||||
name="category"
|
||||
defaultValue={String(product.category?.at(-1)?.id ?? "")}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{parent.length ? `Its parent's: ${path(parent)}` : "None"}
|
||||
</NativeSelectOption>
|
||||
{choices
|
||||
.filter((c) => c.at(-1)!.id !== parent.at(-1)?.id)
|
||||
.map((c) => (
|
||||
<NativeSelectOption key={c.at(-1)!.id} value={String(c.at(-1)!.id)}>
|
||||
{path(c)}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
)}
|
||||
{locked("category") ? (
|
||||
<LockedNote />
|
||||
) : (
|
||||
<FieldDescription>
|
||||
Where it's filed in the shop and inventory: any category T5DE has seen, within its parent's
|
||||
kind of product.
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||
{locked("rating") ? (
|
||||
<p className="text-sm capitalize">{product.rating}</p>
|
||||
) : (
|
||||
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
|
||||
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||
<NativeSelectOption value="adult">Adult</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
)}
|
||||
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
|
||||
{locked("rating") ? (
|
||||
<LockedNote />
|
||||
) : (
|
||||
<FieldDescription>
|
||||
Raising it to adult is immediate; {can("product:rate-lower") ? "lowering it is too." : "lowering it asks moderators."}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="listed">Listing</FieldLabel>
|
||||
{locked("listed") ? (
|
||||
<p className="text-sm">{product.listed ? "Listed" : "Unlisted"}</p>
|
||||
) : (
|
||||
<NativeSelect id="listed" name="listed" defaultValue={product.listed ? "yes" : "no"} className="w-48">
|
||||
<NativeSelectOption value="yes">Listed</NativeSelectOption>
|
||||
<NativeSelectOption value="no">Unlisted</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
)}
|
||||
{locked("listed") ? (
|
||||
<LockedNote />
|
||||
) : (
|
||||
<FieldDescription>Unlisted: left out of search and lists; its link still works.</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
{!mine && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reason">Reason</FieldLabel>
|
||||
<Input id="reason" name="reason" required maxLength={500} className="max-w-md" />
|
||||
<FieldDescription>Shown to the creator in the product's history.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||
</div>
|
||||
@@ -73,7 +210,122 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UploadCard title="Upload a new revision" product={id} />
|
||||
{can("product:lock") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Locks</CardTitle>
|
||||
<CardDescription>Locked fields can only be changed by moderators.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveLocks} fields={{ id }}>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (
|
||||
<label key={f} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="locked"
|
||||
value={f}
|
||||
defaultChecked={product.locked.includes(f)}
|
||||
className="size-4 accent-foreground"
|
||||
/>
|
||||
{fieldNames[f]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
||||
<SubmitButton variant="outline" pendingLabel="Saving…">
|
||||
Save locks
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>History</CardTitle>
|
||||
<CardDescription>Every change to its info, with who made it and why.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No changes yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{entries.map((e) => (
|
||||
<HistoryRow key={e.id} id={id} entry={e} mine={mine} canRevert={can("product:edit")} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{mine && <UploadCard title="Upload a new revision" product={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedNote() {
|
||||
return <FieldDescription>Locked by a moderator.</FieldDescription>;
|
||||
}
|
||||
|
||||
/** A value as the history shows it. */
|
||||
function shown(field: string, v: unknown): string {
|
||||
if (field === "listed") return v ? "listed" : "unlisted";
|
||||
if (field === "category")
|
||||
return Array.isArray(v) ? v.map((c: { name?: string }) => c.name ?? "").join(" › ") : "its parent's";
|
||||
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
|
||||
if (typeof v === "string") return v || "empty";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function HistoryRow({ id, entry: e, mine, canRevert }: { id: string; entry: InfoEntry; mine: boolean; canRevert: boolean }) {
|
||||
const what =
|
||||
e.kind === "publish" ? "published it" : e.kind === "lock" ? "changed the locks" : e.kind === "revert" ? "undid a change" : "edited";
|
||||
return (
|
||||
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{e.by || "Someone"}</span>
|
||||
{!e.byCreator && <Tag>Moderator</Tag>}
|
||||
<span className="text-muted-foreground">{what}</span>
|
||||
<span className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</span>
|
||||
</div>
|
||||
{e.reason && <p className="text-sm">Reason: {e.reason}</p>}
|
||||
{e.kind !== "publish" && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{Object.entries(e.changes).map(([field, c]) => (
|
||||
<li key={field}>
|
||||
<span className="text-foreground">{fieldNames[field] ?? field}</span>
|
||||
{field === "description" ? (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer hover:text-foreground">Before and after</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
|
||||
{shown(field, c.from)}
|
||||
</pre>
|
||||
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
|
||||
{shown(field, c.to)}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
<>
|
||||
: {shown(field, c.from)} → {shown(field, c.to)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{canRevert && (e.kind === "edit" || e.kind === "revert") && (
|
||||
<ActionForm action={revertInfo} fields={{ id, entry: e.id }} className="flex flex-wrap items-center gap-2 space-y-0">
|
||||
{!mine && <Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="h-8 max-w-xs" />}
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Undoing…">
|
||||
Undo
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, RatingTag } from "@/components/products";
|
||||
import { ReportForm } from "@/components/cases";
|
||||
import { formatSize, ProductImage, RatingTag, StateTags } 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> {
|
||||
@@ -18,8 +20,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
|
||||
const { product, revisions, inLibrary, stayOn, mine } = data;
|
||||
const { product, revisions, inLibrary, stayOn, mine, available, catalogs, permissions } = data;
|
||||
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
|
||||
const draft = product.status === "draft";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -27,11 +30,63 @@ 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} />
|
||||
{/* Its state (draft, unlisted, locks) is for those who manage it; anyone may see that it's deleted. */}
|
||||
{permissions.includes("product:edit") ? (
|
||||
<StateTags product={product} />
|
||||
) : (
|
||||
product.deleted && <Tag tone="danger">Deleted</Tag>
|
||||
)}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
{draft && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A draft: only {mine ? "you" : "its creator"} and moderators see it, and nobody can get it until it's
|
||||
published.{" "}
|
||||
{permissions.includes("product:publish") && (
|
||||
<Link href={`/products/${id}/edit`} className="font-medium text-foreground underline-offset-4 hover:underline">
|
||||
Review and publish
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!draft && !product.listed && mine && (
|
||||
<p className="text-sm text-muted-foreground">Unlisted: it's not in search or lists, but anyone with its link can get it.</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{product.tags.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label="Tags">
|
||||
{product.tags.map((t) => (
|
||||
<li key={t}>
|
||||
<Link href={`/products?tag=${encodeURIComponent(t)}`} className="hover:opacity-80">
|
||||
<Tag>{t}</Tag>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{me ? (
|
||||
{draft || (product.deleted && !inLibrary) ? null : me ? (
|
||||
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{inLibrary ? (
|
||||
@@ -41,10 +96,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Remove
|
||||
</SubmitButton>
|
||||
</>
|
||||
) : (
|
||||
) : available ? (
|
||||
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
|
||||
Get
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Members only. Join{" "}
|
||||
{catalogs.map((c, i) => (
|
||||
<span key={c.id}>
|
||||
{i > 0 && (i === catalogs.length - 1 ? " or " : ", ")}
|
||||
<Link href={`/catalogs/${c.id}`} className="font-medium text-foreground underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
{c.joined === "requested" && " (asked)"}
|
||||
</span>
|
||||
))}{" "}
|
||||
to get it.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
@@ -53,11 +122,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Sign in to get it
|
||||
</Link>
|
||||
)}
|
||||
{mine && (
|
||||
{permissions.includes("product:edit") && !(mine && product.deleted) && (
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
Edit
|
||||
{mine ? "Edit" : "Edit info"}
|
||||
</Link>
|
||||
)}
|
||||
{mine && !product.deleted && (
|
||||
<>
|
||||
<ActionForm action={deleteProduct} fields={{ id }}>
|
||||
<SubmitButton
|
||||
variant="outline"
|
||||
pendingLabel="Deleting…"
|
||||
confirm="Delete this product? Nobody new can get it. People who have it keep it."
|
||||
>
|
||||
Delete
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
@@ -69,6 +151,28 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{me && !mine && !draft && <ReportForm about="product" id={id} />}
|
||||
|
||||
{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>
|
||||
@@ -85,6 +189,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
stayOn={stayOn}
|
||||
using={using === r.number}
|
||||
mine={mine}
|
||||
canBlock={permissions.includes("product:remove")}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -108,6 +213,7 @@ function RevisionRow({
|
||||
stayOn,
|
||||
using,
|
||||
mine,
|
||||
canBlock,
|
||||
}: {
|
||||
id: string;
|
||||
revision: Revision;
|
||||
@@ -116,6 +222,8 @@ function RevisionRow({
|
||||
stayOn: number | null;
|
||||
using: boolean;
|
||||
mine: boolean;
|
||||
/** Moderators: each file links to blocking it. */
|
||||
canBlock: boolean;
|
||||
}) {
|
||||
const facts = [
|
||||
formatSize(r.size),
|
||||
@@ -143,6 +251,11 @@ function RevisionRow({
|
||||
<li key={f.name} className="flex gap-3">
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
|
||||
{canBlock && (
|
||||
<Link href={`/console/blocks/${f.sha256}`} className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
||||
Block
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -32,6 +32,42 @@ 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");
|
||||
}
|
||||
|
||||
/** Moderation restores a removed product. */
|
||||
export async function restoreProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
unwrap(await api.products({ id }).unremove.post({ reason: field(form, "reason") }));
|
||||
done(id);
|
||||
revalidatePath("/products");
|
||||
revalidatePath("/console/products");
|
||||
return "Restored.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
@@ -40,24 +76,84 @@ export async function retract(_: ActionState, form: FormData): Promise<ActionSta
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves a product's info. Fields left out of the form (locked ones are disabled) aren't changed. */
|
||||
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const has = (name: string) => form.has(name);
|
||||
const rating = field(form, "rating");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(
|
||||
const listed = field(form, "listed");
|
||||
return attempt(async () => {
|
||||
const { fields, review } = unwrap(
|
||||
await api.products({ id }).patch({
|
||||
name: field(form, "name"),
|
||||
description: typeof form.get("description") === "string" ? String(form.get("description")) : "",
|
||||
...(rating === "adult" ? { rating: "adult" as const } : {}),
|
||||
...(has("name") ? { name: field(form, "name") } : {}),
|
||||
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
|
||||
...(rating === "adult" || rating === "general" ? { rating } : {}),
|
||||
...(has("tags")
|
||||
? {
|
||||
tags: field(form, "tags")
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
: {}),
|
||||
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
|
||||
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
|
||||
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
|
||||
}),
|
||||
);
|
||||
done(id);
|
||||
revalidatePath("/products");
|
||||
revalidatePath(`/products/${id}/edit`);
|
||||
if (review) return `${fields.length ? "Saved. " : ""}Moderators will review lowering the rating (case #${review}).`;
|
||||
return fields.length ? "Saved." : "Nothing changed.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(await api.products({ id }).publish.post());
|
||||
done(id);
|
||||
revalidatePath("/products");
|
||||
revalidatePath("/console/products");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect(`/products/${id}`);
|
||||
}
|
||||
|
||||
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
|
||||
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const fields = form
|
||||
.getAll("locked")
|
||||
.filter((v): v is "name" | "description" | "rating" | "tags" | "listed" | "category" =>
|
||||
["name", "description", "rating", "tags", "listed", "category"].includes(String(v)),
|
||||
);
|
||||
return attempt(async () => {
|
||||
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
|
||||
done(id);
|
||||
revalidatePath(`/products/${id}/edit`);
|
||||
return "Locks saved.";
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts back what a history entry changed. */
|
||||
export async function revertInfo(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
const reason = field(form, "reason");
|
||||
unwrap(
|
||||
await api
|
||||
.products({ id })
|
||||
.history({ entry: Number(field(form, "entry")) })
|
||||
.revert.post(reason ? { reason } : {}),
|
||||
);
|
||||
done(id);
|
||||
revalidatePath(`/products/${id}/edit`);
|
||||
return "Undone.";
|
||||
});
|
||||
}
|
||||
|
||||
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
|
||||
export async function startUpload(
|
||||
catalog: string,
|
||||
|
||||
@@ -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. Then check its info and publish it.{" "}
|
||||
<Link href="/products" className="underline-offset-4 hover:underline">
|
||||
Back to products
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<UploadCard title="Upload" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,52 @@
|
||||
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);
|
||||
export default async function Products({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string | string[]; creator?: string | string[]; tag?: string | string[] }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
|
||||
const q = one(params.q, 100);
|
||||
const creator = one(params.creator, 64);
|
||||
const tag = one(params.tag, 24);
|
||||
const [me, { products }] = await Promise.all([
|
||||
getMe(),
|
||||
api.products
|
||||
.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}), ...(tag ? { tag } : {}) } })
|
||||
.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">
|
||||
{[creator && `By ${creator}.`, tag && `Tagged “${tag}”.`].filter(Boolean).join(" ") || "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" />
|
||||
{creator && <input type="hidden" name="creator" value={creator} />}
|
||||
{tag && <input type="hidden" name="tag" value={tag} />}
|
||||
<Button type="submit" variant="outline">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
{products.length === 0 ? (
|
||||
<Empty>{q ? "Nothing found." : "No products yet."}</Empty>
|
||||
<Empty>{q || tag || creator ? "Nothing found." : "No products yet."}</Empty>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,54 @@
|
||||
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>
|
||||
<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>
|
||||
@@ -50,6 +89,41 @@ export default async function Rooms() {
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { NarrowCard } from "@/components/narrow-card";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { SignUpForm } from "./signup-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Create account" };
|
||||
|
||||
export default async function SignUp() {
|
||||
if (await getMe()) redirect("/account");
|
||||
const { registration } = unwrap(await api.hub.get());
|
||||
const description =
|
||||
registration === "closed"
|
||||
? "One T5DE account for all your IMVU accounts. New accounts need a moderator's approval."
|
||||
: "One T5DE account for all your IMVU accounts.";
|
||||
return (
|
||||
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
|
||||
<NarrowCard title="Create account" description={description}>
|
||||
<SignUpForm />
|
||||
</NarrowCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Pieces shared by the case pages and the places people open cases from.
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { Tag } from "@/components/status";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { report } from "@/app/console/cases/actions";
|
||||
|
||||
export const caseKinds: Record<string, string> = {
|
||||
report: "Report",
|
||||
"rating-review": "Rating review",
|
||||
"removal-request": "Removal request",
|
||||
"catalog-deletion": "Catalog deletion",
|
||||
appeal: "Appeal",
|
||||
};
|
||||
|
||||
/** What resolving each kind of case does (its button). */
|
||||
export const resolveLabel: Record<string, string> = {
|
||||
report: "Resolve",
|
||||
"rating-review": "Lower to general",
|
||||
"removal-request": "Remove the product",
|
||||
"catalog-deletion": "Delete the catalog",
|
||||
appeal: "Uphold the appeal",
|
||||
};
|
||||
|
||||
export function CaseStatus({ status }: { status: string }) {
|
||||
if (status === "open") return <Tag tone="strong">Open</Tag>;
|
||||
if (status === "resolved") return <Tag>Resolved</Tag>;
|
||||
return <Tag>Declined</Tag>;
|
||||
}
|
||||
|
||||
const tagLinks: Record<string, (id: string) => string> = {
|
||||
catalog: (id) => `/catalogs/${id}`,
|
||||
product: (id) => `/products/${id}`,
|
||||
};
|
||||
|
||||
/** What a case is about, linked. */
|
||||
export function CaseAbout({ tags, names }: { tags: Record<string, string | undefined>; names: Record<string, string> }) {
|
||||
const parts = Object.entries(tags).filter((e): e is [string, string] => !!e[1]);
|
||||
if (parts.length === 0) return <span className="text-muted-foreground">nothing</span>;
|
||||
return (
|
||||
<span className="space-x-2">
|
||||
{parts.map(([kind, id]) => {
|
||||
const href = tagLinks[kind]?.(id);
|
||||
return (
|
||||
<span key={kind}>
|
||||
<span className="text-muted-foreground">{kind} </span>
|
||||
{href ? (
|
||||
<Link href={href} className="underline-offset-4 hover:underline">
|
||||
{names[id] ?? id}
|
||||
</Link>
|
||||
) : (
|
||||
(names[id] ?? id)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Report": a reason, sent to moderators (and the hosting catalogs' teams, for products). */
|
||||
export function ReportForm({ about, id, label = "Report" }: { about: "product" | "catalog" | "room" | "person"; id: string; label?: string }) {
|
||||
return (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">{label}</summary>
|
||||
<ActionForm action={report} fields={{ about, id }} className="mt-2">
|
||||
<div className="flex max-w-xl flex-wrap gap-2">
|
||||
<Input name="reason" required maxLength={2000} placeholder="What's wrong?" aria-label="What's wrong" className="min-w-60 flex-1" />
|
||||
<SubmitButton variant="outline" pendingLabel="Sending…">
|
||||
Send report
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { Tag } from "@/components/status";
|
||||
|
||||
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
|
||||
|
||||
/** A catalog's review status: approved, pending or rejected. */
|
||||
/** A catalog's status: approved, pending, rejected, suspended or deleted. */
|
||||
export function CatalogStatus({ status }: { status: string }) {
|
||||
if (status === "approved") return <Tag tone="strong">approved</Tag>;
|
||||
if (status === "rejected") return <Tag tone="danger">rejected</Tag>;
|
||||
if (status === "rejected" || status === "suspended" || status === "deleted") return <Tag tone="danger">{status}</Tag>;
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -32,21 +33,67 @@ export function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutButton() {
|
||||
/** The signed-in person's menu: their picture and name, opening their pages and signing out. */
|
||||
export function ProfileMenu({ username, picture, console }: { username: string; picture: string | null; console: boolean }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const item =
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-muted";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
<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 () => {
|
||||
setPending(true);
|
||||
setSigningOut(true);
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
{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,13 @@
|
||||
import Image from "next/image";
|
||||
import { Bell } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getMe, isModerator } from "@/lib/api";
|
||||
import { getMe } 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,25 +15,34 @@ 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 />
|
||||
<Link
|
||||
href="/notifications"
|
||||
aria-label={me.unread ? `Notifications, ${me.unread} unread` : "Notifications"}
|
||||
className={buttonVariants({ variant: "ghost", size: "icon", className: "relative" })}
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{me.unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-semibold text-background tabular-nums">
|
||||
{me.unread > 99 ? "99+" : me.unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={me.console} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -8,6 +8,23 @@ export function RatingTag({ rating }: { rating: string }) {
|
||||
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
|
||||
}
|
||||
|
||||
/** A product's state, where it isn't plainly published and listed: draft, unlisted, deleted, removed, locked. */
|
||||
export function StateTags({
|
||||
product: p,
|
||||
}: {
|
||||
product: Pick<ProductSummary, "status" | "listed" | "deleted" | "removed" | "locked">;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{p.status === "draft" && <Tag tone="strong">Draft</Tag>}
|
||||
{p.status === "published" && !p.listed && <Tag>Unlisted</Tag>}
|
||||
{p.deleted && <Tag tone="danger">Deleted</Tag>}
|
||||
{p.removed && <Tag tone="danger">Removed</Tag>}
|
||||
{p.locked.length > 0 && <Tag>Locked: {p.locked.join(", ")}</Tag>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 1536 → "1.5 KB". */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -21,24 +38,43 @@ 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" | "listed">;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const category = product.categories.at(-1)?.name;
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<ProductImage src={product.image} />
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||
{product.name}
|
||||
</Link>
|
||||
<span className="flex gap-1">
|
||||
{!product.listed && <Tag>Unlisted</Tag>}
|
||||
<RatingTag rating={product.rating} />
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr
|
||||
}
|
||||
|
||||
/** An IMVU account's age status: blue when it can see adult community products. */
|
||||
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
|
||||
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
|
||||
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
|
||||
if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
|
||||
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
|
||||
return <Tag>Not age verified</Tag>;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -53,7 +53,8 @@ export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?
|
||||
const answer = await send(start.url, start.token, body, setProgress);
|
||||
const value = answer.body as { product?: unknown; message?: unknown } | null;
|
||||
if (answer.status === 201 && typeof value?.product === "string") {
|
||||
router.push(`/products/${value.product}`);
|
||||
// A new product is a draft: on to reviewing its info and publishing it.
|
||||
router.push(product ? `/products/${value.product}` : `/products/${value.product}/edit`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
+16
-1
@@ -37,14 +37,29 @@ export type Person = Data<V1["me"]["get"]>["person"];
|
||||
export type Account = Data<V1["account"]["get"]>;
|
||||
export type Link = Account["links"][number];
|
||||
export type Challenge = Account["challenges"][number];
|
||||
export type AgeInfo = Data<V1["age"]["get"]>;
|
||||
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||
export type AuditEntry = ConsoleData["audit"][number];
|
||||
export type CaseSummary = Data<V1["cases"]["get"]>["cases"][number];
|
||||
export type CasePage = Data<ReturnType<V1["cases"]>["get"]>;
|
||||
export type NotificationEntry = Data<V1["notifications"]["get"]>["notifications"][number];
|
||||
export type TokenList = Data<V1["account"]["tokens"]["get"]>;
|
||||
export type TokenEntry = TokenList["tokens"][number];
|
||||
export type TokenScope = TokenList["scopes"][number];
|
||||
export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>;
|
||||
export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
|
||||
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
|
||||
export type HostedProduct = Data<ReturnType<V1["catalogs"]>["products"]["get"]>["products"][number];
|
||||
export type CatalogTeam = Data<ReturnType<V1["catalogs"]>["team"]["get"]>;
|
||||
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
|
||||
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||
export type Revision = ProductPage["revisions"][number];
|
||||
export type InfoEntry = Data<ReturnType<V1["products"]>["history"]["get"]>["entries"][number];
|
||||
export type ManagedProduct = Data<V1["console"]["products"]["get"]>["products"][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 +95,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)$"
|
||||
@@ -22,6 +22,13 @@ services:
|
||||
# T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api.
|
||||
API_URL: ${API_PUBLIC_URL:-http://localhost:4101}
|
||||
WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100}
|
||||
# open, or closed (new accounts wait for a moderator's approval) until an admin changes it in the console.
|
||||
REGISTRATION: ${REGISTRATION:-open}
|
||||
# Ways to verify age (apps/api/src/index.ts): imvu, and didit with its keys.
|
||||
AGE_PROVIDERS: ${AGE_PROVIDERS:-imvu}
|
||||
DIDIT_API_KEY: ${DIDIT_API_KEY:-}
|
||||
DIDIT_WORKFLOW_ID: ${DIDIT_WORKFLOW_ID:-}
|
||||
DIDIT_WEBHOOK_SECRET: ${DIDIT_WEBHOOK_SECRET:-}
|
||||
ports: ["4101:4101"]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
+73
-12
@@ -7,20 +7,23 @@ requests and type the website's client (Eden). This is the overview.
|
||||
- `/api/v1/*`: the hub's own routes.
|
||||
|
||||
The website reaches the API through its own origin (Next.js rewrites `/api` to it), so sessions are first-party
|
||||
cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`.
|
||||
cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`. Scripts use personal API
|
||||
tokens (`Authorization: Bearer t5de_pat_...`, docs/console.md): those work only on routes that check a permission
|
||||
(the rest answer 403 `token_not_allowed`, and a wrong, revoked or expired token 401 `bad_token`).
|
||||
|
||||
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)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
|
||||
| `POST /api/auth/sign-in/username` | `{username, password}` |
|
||||
| `POST /api/auth/sign-out` | |
|
||||
| `GET /api/v1/me` | `{person}` |
|
||||
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, `console`: whether the console is for them, and `unread`: their unread notifications |
|
||||
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
|
||||
|
||||
## Proxies
|
||||
|
||||
@@ -29,7 +32,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 +48,53 @@ 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, category, status, listed, tags, locked, deleted, removed, publishedAt,
|
||||
createdAt, updatedAt}`; `parent` and `image` come from the latest revision (its IMVU parent's public info), and
|
||||
`categories` too unless a category was chosen for it (`category`).
|
||||
`GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU category ID), `tag`, `catalog` 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;
|
||||
@@ -56,17 +102,32 @@ person-level providers (phone wallets, document checks) are started here.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks |
|
||||
| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) |
|
||||
| `GET /api/v1/age` | `{providers: [{id, label, vendor, scope}], checks}`: the providers in use and the person's own checks (newest first; status `pending`, `complete`, `failed` or `revoked`) |
|
||||
| `POST /api/v1/age/:provider/start` | `{returnURL}` (a path on the website, or an address on it) → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API). An unfinished check is given again. 409 `refused`: already verified, or a limit reached (`message` says which); 502 `provider_unavailable` |
|
||||
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
|
||||
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature |
|
||||
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) |
|
||||
|
||||
## Console (moderators; roles: admins)
|
||||
## Console
|
||||
|
||||
Permissions, roles and scopes are in docs/console.md. A missing permission answers 403 `forbidden` with
|
||||
`missing: {permission, scope}`. Catalog management routes are in docs/products.md.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/console` | `{counts, connections, online, audit}` |
|
||||
| `GET /api/v1/console/people?q=` | `{people}` |
|
||||
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `todo` counts what waits on them (`cases` they could decide, `drafts`, `memberRequests`; for moderators `pendingCatalogs`, `waitingPeople`), `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
|
||||
| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` |
|
||||
| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) |
|
||||
| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is |
|
||||
| `GET /api/v1/console/products?q=&status=&all=1` | `{products, moderator, all}`: the person's own products (with `all=1`, moderators get everyone's), drafts, unlisted, deleted and removed ones included, most recently changed first; each with `libraries` (how many people have it). `status`: `draft`, `published` (and listed), `unlisted`, `locked`, `deleted` or `removed`; `q` matches the name, ID or creator. 403 for people with no products |
|
||||
|
||||
Moderators:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) |
|
||||
| `POST /api/v1/console/people/:id/approve` | `{person}` |
|
||||
| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it |
|
||||
| `GET /api/v1/console/settings`, `PUT` (admins) | `{registration}`: `open` or `closed` (default: the API's `REGISTRATION`, else open) |
|
||||
| `GET /api/v1/console/people/:id` | `{person, links, computers}` |
|
||||
| `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) |
|
||||
| `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators |
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# Permissions, the console, audit and stats
|
||||
|
||||
How people manage what they control on the hub: products, catalogs, importers, hosted rooms, and the hub itself.
|
||||
The products contract is `products.md`, imports are `imports.md`; this is who may do what, and what they see.
|
||||
|
||||
## Permissions (**Decided**, 2026-11-04)
|
||||
|
||||
- **Code checks permissions, never roles.** Every check is `can(person, permission, target)`. Permissions are
|
||||
`resource:action` strings (list below).
|
||||
- **A role is a named bundle of permissions**, given to a person at a **scope**: the hub (everything), a catalog, a
|
||||
product or a hosted room.
|
||||
- **Broader scopes cover narrower ones.** A permission held hub-wide covers every catalog, product and room. One held
|
||||
on a catalog covers that catalog's members, importer, grants and hosting (not the products themselves: delisting
|
||||
a product from the catalog is `hosting:delist` on the catalog; removing it everywhere is `product:remove`, which
|
||||
only hub-wide roles have).
|
||||
- **Owners are derived**, not stored as assignments: a product's creator (`product.creator_id`), a catalog's owner
|
||||
(`catalog.owner_id`), a hosted room's owner. They can't fall out of step with those columns; they change only by
|
||||
transfer.
|
||||
- **Giving roles**: you may give (or take away) a role at a scope only if you hold every permission in it at that
|
||||
scope, so nobody hands out more than they have. Built-in roles can't be deleted or changed (except by admins,
|
||||
hub-wide). Every change is audited.
|
||||
- **Custom roles** (later): the model allows them (a catalog owner making "Member approver" with only
|
||||
`member:approve`); built-in roles come first.
|
||||
|
||||
```
|
||||
role(id, name, scope_type, permissions[], builtin)
|
||||
role_assignment(person, role, scope_type, scope_id) -- scope_id null for the hub
|
||||
```
|
||||
|
||||
### Built-in roles
|
||||
|
||||
| Role | Scope | Permissions |
|
||||
| --- | --- | --- |
|
||||
| Admin | hub | all |
|
||||
| Moderator | hub | `product:*` (not `transfer`), `revision:retract`, `catalog:suspend`, `catalog:delete-review`, `catalog:approve`, `hosting:delist`, `import:grant-on-behalf`, `import:withdraw`, `import:pause`, `case:*`, `person:approve`, `person:ban`, `person:link-cap`, `person:notes`, `audit:read`, `stats:read`, `room:*` |
|
||||
| Catalog owner (derived) | catalog | `catalog:edit`, `catalog:rotate-key`, `catalog:delete-request`, `catalog:transfer`, `team:manage`, `member:*`, `hosting:delist`, `import:*` (not `grant-on-behalf`), `case:read`, `case:resolve`, `audit:read`, `stats:read` |
|
||||
| Catalog operator | catalog | as the owner, without `catalog:delete-request`, `catalog:transfer`, `team:manage` |
|
||||
| Creator (derived) | product | `product:edit`, `product:publish`, `product:delete`, `product:restore`, `product:transfer`, `revision:upload`, `revision:retract`, `hosting:choose`, `case:read`, `audit:read`, `stats:read` |
|
||||
| Room owner (derived) | hosted room | `room:*` |
|
||||
| Room moderator | hosted room | `room:kick`, `room:ban`, `room:mute` |
|
||||
|
||||
Hub-wide `admin` and `moderator` keep living in Better Auth's `user.role` (its admin plugin needs it); the permission
|
||||
check maps them to the bundles above. A creator with `product:edit` can't change fields a moderator locked
|
||||
(`product:edit-locked`), and lowering a rating is `product:rate-lower` (moderators only; creators ask for a review).
|
||||
|
||||
### Permissions
|
||||
|
||||
| Resource | Actions |
|
||||
| --- | --- |
|
||||
| `product` | `edit`, `edit-locked`, `lock`, `rate-lower`, `publish`, `delete`, `restore`, `remove`, `unremove`, `transfer` |
|
||||
| `revision` | `upload`, `retract` |
|
||||
| `catalog` | `approve`, `edit`, `rotate-key`, `suspend`, `delete-request`, `delete-review`, `transfer` |
|
||||
| `team` | `manage` |
|
||||
| `member` | `approve`, `invite`, `remove`, `ban` |
|
||||
| `hosting` | `choose`, `delist` |
|
||||
| `import` | `grant`, `grant-on-behalf`, `accept`, `run`, `pause`, `withdraw` |
|
||||
| `case` | `read`, `resolve`, `assign` |
|
||||
| `audit` | `read`, `read-sensitive` |
|
||||
| `stats` | `read` |
|
||||
| `person` | `approve`, `ban`, `link-cap`, `notes`, `role`, `view-as` |
|
||||
| `room` | `edit`, `close`, `kick`, `ban`, `mute` |
|
||||
| `settings` | `edit` |
|
||||
|
||||
`audit:read-sensitive` (IP addresses, sessions), `person:role`, `person:view-as` and `settings:edit` are admins'.
|
||||
|
||||
### "Why can't I?"
|
||||
|
||||
A refused action answers 403 `forbidden` with `missing: {permission, scope}` (e.g. `{permission: "member:approve",
|
||||
scope: {catalog: "c1"}}`), and the website says which permission it needed. **View as** (admins,
|
||||
`person:view-as`): the console as another person sees it, read-only, audited, with a banner; nothing can be changed
|
||||
while viewing.
|
||||
|
||||
### Two-factor sign-in
|
||||
|
||||
Anyone holding a sensitive permission must have two-factor sign-in on (Better Auth's `twoFactor` plugin, TOTP with
|
||||
backup codes): `product:remove`, `person:ban`, `person:role`, `audit:read-sensitive`, `catalog:transfer`,
|
||||
`catalog:suspend`, `import:grant-on-behalf`, `settings:edit`. Without it, those actions answer 403
|
||||
`two_factor_required` and the console asks them to set it up. Giving someone such a role reminds them.
|
||||
|
||||
## The console
|
||||
|
||||
One console at `/console` for everyone who controls something: a creator with a product, a catalog's team, a
|
||||
hosted room's owner or moderators, moderators and admins. Others don't see the link. Every page and action asks
|
||||
for a permission, and the API checks it again (the website only hides what can't be used).
|
||||
|
||||
Sections (each shown only to those with something in it):
|
||||
|
||||
- **Overview**: charts and to-dos (open cases, member requests, failed imports, drafts) for what the person
|
||||
controls.
|
||||
- **Products**: their products (everyone's for moderators), drafts, info history, locks, stats.
|
||||
- **Catalogs**: the catalogs they're on the team of (all for admins and moderators): settings, key, team, members
|
||||
and invites, hosted products, cases, the importer (grants, queue, requests) when it has one, health and storage.
|
||||
- **Rooms**: their hosted rooms.
|
||||
- **Cases**: reports, appeals and reviews about what they control.
|
||||
- **People**: moderators and admins.
|
||||
- **Blocks**: those who remove products hub-wide (`product:remove`): blocked files, a file's page (the products using it;
|
||||
block, unblock). Each file in a product's revisions links to its page for them.
|
||||
- **Audit**: scoped (below).
|
||||
- **Settings**: admins (registration, announcements, roles).
|
||||
|
||||
No catalog is special: importer pages are part of any catalog with imports on; what only admins do (granting on a
|
||||
creator's behalf) is a hub-wide permission usable on every such catalog.
|
||||
|
||||
## Audit log
|
||||
|
||||
- Every change is an entry: `{at, actor, action, tags, detail}`. `tags` are the resources it touches, e.g.
|
||||
`{catalog: "c1", product: "p9"}` for "operator delisted p9 from c1", `{person: "x"}` for a ban.
|
||||
- **Who sees an entry**: anyone with `audit:read` at a scope covering any of its tags. Admins and moderators see
|
||||
everything.
|
||||
- **Fields by permission**: IP addresses and sessions only with `audit:read-sensitive`. People appear by hub
|
||||
username; linked IMVU accounts appear only to moderators (alts stay private). Catalog owners see moderators'
|
||||
actions on their catalog and their products, with the moderator's name and reason.
|
||||
- **Filters**: resource, actor, action, date range, text; admins also get "everything a person did" and
|
||||
"everything that happened to X". Every detail page links to its entries.
|
||||
- Retention (later): entries kept indefinitely for now; a rule per action type later.
|
||||
|
||||
```
|
||||
audit(id, at, actor, action, tags jsonb, detail jsonb, ip) -- GIN index on tags
|
||||
```
|
||||
|
||||
The existing `target` column becomes tags (`target` kept for old entries).
|
||||
|
||||
## Cases: reports, appeals and reviews
|
||||
|
||||
One queue for everything someone has to decide. A case has a kind, the resources it's about (tags, as the audit
|
||||
log), who opened it, a status (`open`, `resolved`, `declined`), an assignee, a resolution and a thread of notes.
|
||||
|
||||
| Kind | Opened by | Decided by |
|
||||
| --- | --- | --- |
|
||||
| `report` | anyone signed in, about a product, catalog, room or person | the catalog's team (for products it hosts) and moderators |
|
||||
| `rating-review` | a creator lowering adult to general | moderators (`product:rate-lower`) |
|
||||
| `removal-request` | a catalog's team asking to remove a product everywhere | moderators |
|
||||
| `catalog-deletion` | a catalog's owner (deleting goes to review for now) | moderators (`catalog:delete-review`) |
|
||||
| `appeal` | the person affected by a removal, ban, delisting or decline (one per action) | a different moderator than the one who acted |
|
||||
|
||||
- People see cases they opened or are assigned, and cases about what they control (`case:read` at a covering scope),
|
||||
except reports: those only the reporter, the hosting catalogs' teams (reports about products) and moderators see,
|
||||
and only the reporter and moderators see who reported (a report's audit entry isn't tagged with what it's about).
|
||||
- Who decides: moderators, by the kind's permission (`case:resolve`; `product:rate-lower` for rating reviews,
|
||||
`product:remove` for removal requests, `catalog:delete-review` for deletions); reports about a product also the
|
||||
teams of the catalogs hosting it (`case:resolve` there). An appeal goes to another moderator than the one who acted;
|
||||
an admin may decide one of their own, which the audit log flags (`ownAction`).
|
||||
- Resolving a rating review lowers the rating (as an edit, in the product's history); a removal request removes the
|
||||
product; a deletion request deletes the catalog (status `deleted`; its changes feed says so). Reports and appeals
|
||||
record the decision, and the moderator acts where it's done: a removed product is restored (`POST
|
||||
/api/v1/products/:id/unremove`), an edit undone from the history, a catalog reinstated, a member unbanned.
|
||||
- Appealable actions: `product.remove`, `product.edit`/`lock`/`revert` by others (the creator), `catalog.delist`
|
||||
(the product's creator), `catalog.suspend`/`reject` (the owner), `catalog.member_ban`/`member_decline` (the
|
||||
person). The audit log marks the entries a person may appeal (`appealable`).
|
||||
- Reports are limited per person (10 an hour), one open report per person and resource.
|
||||
- Not built yet: canned reasons, moderator notes and second approval (below).
|
||||
- **Canned reasons** (admins edit the list) for removals, bans, declines and edits; free text can be added.
|
||||
- **Moderator notes**: private notes on people and products (`person:notes`), shown in the console only.
|
||||
- **Second approval** for the most destructive actions: banning a person, removing a catalog and transferring a
|
||||
catalog need a second moderator's approval (a case the first opens); admins can act alone in an emergency, which
|
||||
is flagged in the audit log.
|
||||
|
||||
## Notifications
|
||||
|
||||
One table; shown on the website (a bell, unread count), on the proxy's realtime connection
|
||||
(`{"type": "notification"}`, so the client can show them later), and by email for types the person opts into.
|
||||
|
||||
Built (2026-11-05): `product_edited` (by someone other than the creator, with the reason), `product_removed`,
|
||||
`product_restored`, `case_decided`, `case_note`, `membership_decided`. Not yet: email (the hub sends none yet) and its
|
||||
settings, and the import, grant, new-revision and request-digest types.
|
||||
|
||||
Types: a moderator edited your product (with the reason); a case about you or yours was opened or decided; your
|
||||
membership request was decided; your import finished or failed; your catalog's deletion request was decided; a
|
||||
product in your library has a new revision; a grant was made or withdrawn; someone requested your products
|
||||
(daily digest).
|
||||
|
||||
```
|
||||
notification(id, person, type, tags, data, created_at, read_at)
|
||||
notification_setting(person, type, email boolean)
|
||||
```
|
||||
|
||||
## API tokens
|
||||
|
||||
Personal tokens for scripts: a name, an expiry (at most a year), and a subset of the person's own permissions at
|
||||
chosen scopes (e.g. `revision:upload` on two products, `member:approve` on a catalog). Sent as `Authorization:
|
||||
Bearer t5de_pat_<random>`; only the hash is stored; shown once. A token never has more than its owner holds now: the
|
||||
check is the intersection, so losing a role takes it from tokens too. Listed and revocable in the account page;
|
||||
every use updates `last_used_at`; actions are audited as the person, marked "by token <name>".
|
||||
|
||||
Built (2026-11-05):
|
||||
- Made, listed and revoked on the account page (a website session only: a token can't make tokens). Each grant is
|
||||
a permission the person holds now at the hub (moderators and admins), a catalog whose team they're on, or a
|
||||
product of theirs (400 `not_held` otherwise). At most 20 active tokens, 50 grants, 365 days.
|
||||
- Usable on the routes that check a permission (product info, publish, locks, history; uploading new revisions of a
|
||||
product, `revision:upload`, and retracting them; a catalog's settings, key, team, members, invites and hosted
|
||||
products; blocks; assigning cases). Other routes refuse tokens (403 `token_not_allowed`), including making a new
|
||||
product, reports and the console. On those routes every check is the intersection, so a moderator's token granted
|
||||
`product:remove` hub-wide can block files but not suspend catalogs, and losing the role takes it from the token.
|
||||
- The audit log marks actions by token (`detail.token`: its name); making and revoking one is audited
|
||||
(`token.create`, `token.revoke`).
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/account/tokens` | `{tokens: [{id, name, grants: [{permission, scope, name}], status (active, expired, revoked), createdAt, expiresAt, lastUsedAt, revokedAt}], scopes: [{scope, name, permissions}], maxDays}`: `scopes` is what a token could be given |
|
||||
| `POST /api/v1/account/tokens` | `{name, days, grants: [{permission, scope: {type: "hub"} or {type: "catalog" or "product", id}}]}` → 201 `{token, info}`; `token` is shown only here |
|
||||
| `DELETE /api/v1/account/tokens/:id` | revokes it → 204 |
|
||||
|
||||
```
|
||||
api_token(id, person, name, hash, grants jsonb, expires_at, last_used_at, created_at, revoked_at)
|
||||
```
|
||||
|
||||
## Stats and charts
|
||||
|
||||
Charts in the console (shadcn/ui charts, Recharts), following the website's look: neutral, light and dark, color only
|
||||
for meaning.
|
||||
|
||||
| Who | Charts |
|
||||
| --- | --- |
|
||||
| everyone | activity on their resources over time, open cases |
|
||||
| creators | library adds per product per day, people wearing them this week, import requests |
|
||||
| catalog teams | members over time, uploads per day, products by rating, storage, cases opened and resolved, importer runs and failures |
|
||||
| admins | sign-ups and approvals, proxies online, products and revisions per day, library adds, cases, catalogs by status, age checks against the provider's monthly budget |
|
||||
|
||||
- A nightly job rolls counts up into `stat_daily(scope_type, scope_id, metric, day, value)`; "now" figures are live
|
||||
counters. Charts never scan the raw tables.
|
||||
- **Only totals.** Counts of people (wearers, adds, members) under 5 show as "fewer than 5", so creators can't pick
|
||||
out individuals.
|
||||
- Catalogs may report daily download totals (`POST /api/v1/catalog/stats`); optional, so other people's catalogs
|
||||
don't have to.
|
||||
- Client usage stays in Aptabase; these are the hub's own data.
|
||||
|
||||
## Routes
|
||||
|
||||
`/api/v1/console/*` (signed in; each checks its permission). Lists take `limit` and a cursor.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/console` | `{sections, counts, todo}`: what this person may see |
|
||||
| `GET /api/v1/console/permissions?scope=...` | the person's permissions at a scope (the website uses it to show actions) |
|
||||
| `GET /api/v1/console/audit?tag=&actor=&action=&from=&to=&q=` | entries the person may see, newest first |
|
||||
| `GET /api/v1/console/stats?scope=&metric=&from=&to=` | `{series: [{day, value}]}` |
|
||||
| `GET /api/v1/cases?status=&kind=&mine=1&before=&limit=` | `{cases, before, moderator}`: the cases the person may see (below), newest first |
|
||||
| `POST /api/v1/cases` | `{kind, about?, catalog?, audit?, reason}` → 201 `{case}`. `report`: `about` names one product, catalog, room (its number) or person (username); 10 an hour (429 `rate_limited`), one open per person and resource (409 `already_reported`), not yourself. `removal-request`: `about.product`, hosted by `catalog`, whose `hosting:delist` the person needs. `appeal`: `audit`, an entry of an action that affected the person (409 `appealed` if they appealed it already) |
|
||||
| `GET /api/v1/cases/:id` | `{case, notes, canDecide, canAssign}`; internal notes only for those deciding and moderators |
|
||||
| `POST /api/v1/cases/:id/notes` | `{body, internal?}`: a note; the opener's reach the assignee and others' reach the opener as notifications (internal ones reach nobody) |
|
||||
| `POST /api/v1/cases/:id/assign` | (`case:assign`) `{to: username or null}`: a moderator |
|
||||
| `POST /api/v1/cases/:id/decide` | `{decision: "resolve" or "decline", resolution}`: by those who may decide it (below); resolving carries out a rating review, removal request or deletion request; the opener is told |
|
||||
| `GET /api/v1/notifications?before=` | `{notifications: [{id, type, tags, data, at, read}], unread, before}`, newest first, 50 a page |
|
||||
| `POST /api/v1/notifications/read` | `{ids?}` (all if left out) → `{unread}` |
|
||||
| `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens (above) |
|
||||
| `GET/PUT/DELETE /api/v1/roles/:scopeType/:scopeId/:person` | role assignments at a scope |
|
||||
| `POST /api/v1/console/view-as/:person` | (admins) start viewing as someone, read-only |
|
||||
|
||||
## Later
|
||||
|
||||
- Custom roles and their editor.
|
||||
- Time-limited role assignments (a temporary moderator for an event).
|
||||
- Announcement banner and maintenance mode (admins).
|
||||
- Retention rules for the audit log and soft-deleted records.
|
||||
- Webhooks (e.g. to Discord) for a catalog's cases and uploads.
|
||||
- Health alerts for catalog teams when the hub can't reach their catalog; storage quotas.
|
||||
- CSV export of stats.
|
||||
+69
-9
@@ -100,6 +100,9 @@ age, not verified) and `tagline`.
|
||||
- **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked:
|
||||
offer to link. Linked to another hub account: no community features that session.
|
||||
- **Bans** apply to the person, so all linked accounts.
|
||||
- **Registration** is open or closed, an admin's setting in the console (default from `REGISTRATION`; staging is
|
||||
meant to be closed, for testers only). Closed: anyone can sign up, but the account can only sign in until a
|
||||
moderator approves it (or declines it, deleting it); the hub treats it as signed out everywhere else.
|
||||
|
||||
## Age (**Decided**: free, several providers, vendor-neutral)
|
||||
|
||||
@@ -112,8 +115,11 @@ No payment for verification (adult content rules out mainstream merchants of rec
|
||||
2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet,
|
||||
asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or
|
||||
birth date reach it. Scope: the person.
|
||||
3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person
|
||||
(the liveness step stops someone using another person's ID).
|
||||
3. **ID document and selfie** (live, `didit`): Didit's hosted check: ID document $0.15, passive liveness $0.10 and
|
||||
face match $0.05, so $0.30 a check, paid from the free plan's $10 of credit a month (since 2026-11-01; the 500
|
||||
free checks a month are gone), so about 30 a month unless topped up. Scope: the person (liveness and face match
|
||||
stop someone using another person's ID). Over 18 is worked out from the age Didit reads off the document;
|
||||
nothing else is kept. With so few checks, phone wallets (free) should become the main way.
|
||||
|
||||
### How it's built
|
||||
|
||||
@@ -128,14 +134,25 @@ No payment for verification (adult content rules out mainstream merchants of rec
|
||||
webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is
|
||||
configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
|
||||
one new adapter; nothing else changes.
|
||||
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature.
|
||||
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature. The provider's
|
||||
latest answer stands: one taken back after approval (say, by a manual review) becomes `revoked`.
|
||||
- **Limits** on checks of a person, for every provider: one unfinished check at a time (starting again gives the
|
||||
same one), at most 3 started in 30 days, none once verified, and a provider's monthly cap (`monthlyLimit`; Didit's
|
||||
`DIDIT_MONTHLY_LIMIT`, default 30), counted from the checks started this month (UTC).
|
||||
- People are sent back only to the hub's website (`returnURL` is a path on it, or an address on `WEB_URL`).
|
||||
- Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room
|
||||
channels for viewers who aren't adult and opted in), and the viewer's proxy.
|
||||
|
||||
### Setting up Didit
|
||||
|
||||
In Didit's console (business.didit.me): an application; a KYC workflow with ID verification, passive liveness and
|
||||
face match only (no IP analysis, AML or proof of address, which cost extra; no white label, $0.10 a session more);
|
||||
an API key; and a webhook destination
|
||||
`<API_URL>/api/v1/age/webhook/didit` with its secret. Then the API's environment: `AGE_PROVIDERS=imvu,didit`,
|
||||
`DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. Use the sandbox application on staging.
|
||||
|
||||
### To add the planned providers
|
||||
|
||||
- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning
|
||||
only "over 18". Confirm its free monthly allowance covers age checks.
|
||||
- Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP,
|
||||
mdoc); Apple and the EU wallet through the same browser API where supported.
|
||||
|
||||
@@ -233,6 +250,16 @@ number, `productdata/<id>/<revision>/`) but hardly shows them; T5DE shows them o
|
||||
the link.
|
||||
- **Seeing versus wearing** (**Decided**): anyone can *see* any catalog's products on someone else (subject to their
|
||||
own age settings); getting and wearing them needs membership where the catalog requires it.
|
||||
- **Managing catalogs** (**Decided**, 2026-11-04): teams (owner and operators), members and invites, settings, key
|
||||
rotation, delisting what they host, suspension by moderators; deleting a catalog goes to moderators' review for
|
||||
now. Revisions can be mirrored to more catalogs. Routes in products.md, permissions in console.md.
|
||||
|
||||
### Imported IMVU products (**Decided**, 2026-11-04)
|
||||
|
||||
Creators who sell on IMVU can grant a catalog permission to import their IMVU products (the whole shop or chosen
|
||||
ones); the catalog's importer (`t5de-importer`) fetches them from IMVU and hosts them as ordinary T5 products, so
|
||||
responsibility stays with catalogs and nothing changes for proxies. Anyone can request a product or shop; admins can
|
||||
record a creator's consent on their behalf. See imports.md.
|
||||
|
||||
## In the client's shop and inventory (planned, the user's goal)
|
||||
|
||||
@@ -287,8 +314,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 +332,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).
|
||||
|
||||
@@ -328,6 +356,12 @@ Profile, products across all catalogs, follows, and release notifications (over
|
||||
pushed to every proxy; ban a person; close a hosted room; mute or ban in rooms.
|
||||
- **Console** on the hub's website: report queue, live views (rooms, reports) over server-sent events, actions,
|
||||
audit log. Catalog operators get their catalog's queue there too.
|
||||
- **Permissions** (**Decided**, 2026-11-04): code checks `resource:action` permissions; roles are bundles of them
|
||||
given at a scope (hub, catalog, product, room). Everyone who controls something gets the console, showing only
|
||||
what their permissions cover, with a scoped audit log, cases (reports, appeals, reviews), notifications, API
|
||||
tokens and charts. See console.md.
|
||||
- **Product info**: new uploads are drafts the creator reviews (title, description, rating, tags, category, listed)
|
||||
and publishes; moderators can edit any product's info with a reason shown to the creator, and lock fields.
|
||||
|
||||
## Payments later (**Decided**: free first)
|
||||
|
||||
@@ -410,7 +444,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
|
||||
@@ -428,6 +464,30 @@ The proxy streams and adds no waiting.
|
||||
6. Better looks (renderer work; can run alongside 3 or later).
|
||||
7. Later: payments.
|
||||
|
||||
## Next (2026-11-04)
|
||||
|
||||
Phases for products, catalogs and imports (products.md, console.md, imports.md):
|
||||
|
||||
1. Permissions and console foundations: the permission check, roles, the console for everyone who controls
|
||||
something, a tagged audit log, membership enforced, catalog settings, team and key rotation, suspension.
|
||||
**Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases
|
||||
(phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles.
|
||||
2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category.
|
||||
**Done** (2026-11-05). A product's category is chosen from the IMVU category paths the hub has seen on parents,
|
||||
within its parent's top-level category; IMVU's own category tree (if it has a public one) would replace those
|
||||
(to look into with the importer spike).
|
||||
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
|
||||
two-factor sign-in for sensitive permissions. Cases and notifications **done** (2026-11-05), without email, canned
|
||||
reasons, moderator notes or second approval. File blocks and API tokens **done** (2026-11-05); two-factor
|
||||
sign-in next.
|
||||
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
|
||||
5. Charts and stats; mirroring between catalogs.
|
||||
|
||||
Later: custom roles; time-limited roles; announcements and maintenance mode; audit retention; webhooks; catalog
|
||||
health alerts and storage quotas; CSV exports; scheduled publishing; revision diffs; product collections; following
|
||||
creators; favorites and wishlists; search by catalog and gender; outfit links; trying a product on in the client;
|
||||
previews drawn by t5de-renderer; import dry runs, flattened imports, and the client's "Get on T5DE" button.
|
||||
|
||||
## Open questions
|
||||
|
||||
- The takedown contact and legal entity for the hub itself.
|
||||
|
||||
@@ -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.
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Imported IMVU products
|
||||
|
||||
Creators who sell on IMVU can let T5DE users wear their IMVU products for free: a catalog imports the products from
|
||||
IMVU, hosts them as ordinary T5 products, and serves them to its members. Decided 2026-11-04 (the user's
|
||||
discussion). The contract with catalogs and proxies is in `products.md`; permissions and the console are in
|
||||
`console.md`.
|
||||
|
||||
## Why the catalog imports
|
||||
|
||||
- **Responsibility stays with catalogs**, as for uploads: the catalog that imports a product stores and serves its
|
||||
files, and its takedown contact answers for them. End users only download from catalogs, as for any T5 product.
|
||||
- **Nothing changes for proxies**: an imported product is a hub product with revisions. No aliasing of IMVU IDs, no
|
||||
re-signed IMVU requests.
|
||||
- **Only consenting creators**: nothing is imported without a grant from the creator (or one an admin records on
|
||||
their behalf, with evidence).
|
||||
|
||||
## Words
|
||||
|
||||
- **Grant**: a creator's consent for one catalog to import their products: the whole shop, or listed products.
|
||||
- **Importer** (`t5de-importer`): a program that fetches products from IMVU and uploads them to its catalog. Run by
|
||||
the catalog's operator beside an ordinary `t5de-catalog`. T5DE runs one for its own import catalog; any catalog
|
||||
may enable imports.
|
||||
- **Import job**: one product to import or re-check, queued at the hub.
|
||||
- **Request**: someone asking for a creator's product, or whole shop, to be on T5DE.
|
||||
- **Unclaimed creator**: a placeholder creator for IMVU creators without a hub account, whose consent an admin
|
||||
recorded. Claimed when they join and link that IMVU account.
|
||||
|
||||
## What's imported
|
||||
|
||||
- **The product's own files**: its CFL's entries, with the 16x16 placeholder textures replaced by the full-size
|
||||
originals its `_contents.json` lists (fetched from IMVU's CDN beside the CFL), and without `_contents.json` (packs
|
||||
don't use it). Its `index.xml` keeps its `__DATAIMPORT` of the parent.
|
||||
- **The parent stays on IMVU** (**Decided**, for now): the revision's `parent` is the IMVU product it derives from,
|
||||
as for uploaded products, and the client loads it from IMVU as it does for anyone's outfit. Option B
|
||||
(**Decided**): any product the creator made may be imported, whatever it derives from; deriving from a derivable
|
||||
product is what IMVU's derive permission allows. If keeping the parent on IMVU becomes a problem, imports can be
|
||||
flattened later (the whole chain in one product, parent 0).
|
||||
- **Info**: name and rating from IMVU's public API (`api.imvu.com/product/product-<id>`: `product_name`, `rating`,
|
||||
`creator_cid`), the higher of IMVU's rating and the files' `ProductRating`; a description saying it's imported
|
||||
with the creator's permission, linking its IMVU page. Creators edit it afterwards like any product.
|
||||
- **Revisions**: each IMVU revision imported becomes a hub revision ("Imported from IMVU revision 12"). Revision
|
||||
history and staying on a revision work as for any product.
|
||||
- One hub product per IMVU product (`product.imvu_product`, unique). Two catalogs importing the same product mirror
|
||||
the same revisions.
|
||||
|
||||
## Grants
|
||||
|
||||
```
|
||||
import_grant(id, catalog, creator person, imvu_id, scope ("shop" | "products"), products bigint[], exclude bigint[],
|
||||
listed, consent_version, consented_at, consent_kind ("verified" | "recorded"), evidence, recorded_by,
|
||||
proof_checked_at, accepted_at, accepted_by, withdrawn_at, status ("pending" | "active" | "suspended" | "withdrawn"))
|
||||
```
|
||||
|
||||
- **Made by the creator**: on the hub website, "Bring my IMVU shop": choose one of their linked IMVU accounts, a
|
||||
catalog with imports on, the scope (whole shop with exclusions, or listed products), listed or unlisted, and agree
|
||||
to the consent text. The text is versioned; the version and time are stored.
|
||||
- **Accepted by the catalog**: the catalog's team (`import:accept`) accepts or declines it, so nobody pushes imports
|
||||
onto a catalog without its operator agreeing. Until then it's `pending`.
|
||||
- **Recorded by an admin** (`import:grant-on-behalf`, hub-wide; for creators who won't make an account): the IMVU
|
||||
creator (ID or name), scope, catalog, and **evidence** (required: how they agreed, e.g. "Discord DM, 2026-11-04",
|
||||
optionally a screenshot). Optional **proof without an account**: the console gives a code to send the creator;
|
||||
they put it in their IMVU tagline, and the hub checks it as for linking. Shown as "consent verified" or "consent
|
||||
recorded by admin". The products belong to an unclaimed creator.
|
||||
- **Scope "shop"**: any product whose `creator_cid` is the grant's IMVU account, including new ones, except
|
||||
exclusions. **Scope "products"**: only those listed; the creator can add more (requests show as suggestions).
|
||||
- **Changes**: the creator (or an admin, for recorded grants) changes the scope, list and exclusions any time.
|
||||
Removing a product from a grant withdraws it (below).
|
||||
- **Ending**: the creator withdraws the grant, or the catalog's team ends it, or the IMVU account's link moves to
|
||||
another person (the grant is `suspended` until the new owner consents again), or the account is unlinked
|
||||
(`suspended`). Admins can withdraw any grant.
|
||||
|
||||
### Unclaimed creators and claiming
|
||||
|
||||
An unclaimed creator is a hub person without credentials (can't sign in), named after the IMVU account ("Bar, on
|
||||
IMVU"), marked unclaimed. When someone links that IMVU account, the hub offers them the claim: the products, grants,
|
||||
requests and stats move to them, and the placeholder is deleted. Until then, withdrawal goes through admins
|
||||
(contacting T5DE, or the catalog's takedown contact).
|
||||
|
||||
## Requests
|
||||
|
||||
```
|
||||
import_request(person, imvu_creator, imvu_product (0: the whole shop), created_at) -- unique per person and target
|
||||
```
|
||||
|
||||
- Anyone signed in can request a product (by IMVU ID or from its shop page in the client) or a creator's whole shop.
|
||||
At most 50 a day per person; one per person and target, so counts can't be inflated.
|
||||
- The hub looks up the product's creator (public API, cached in `imvu_product_info`).
|
||||
- **Covered by an active grant**: queued for import at once; the person gets the product in their library when it's
|
||||
done (pending until then; nothing on screen waits for it).
|
||||
- **Not covered, grant by products**: a suggestion for the creator ("12 people asked for X"), added with one click.
|
||||
- **No grant**: counted. The creator sees the demand when they bring their shop; the hub's creator page for a shop
|
||||
that hasn't joined can show "requested by 47 people". The hub never contacts creators through IMVU.
|
||||
- Admins see request counts per IMVU creator in the console (whom it's worth asking).
|
||||
|
||||
## Import jobs
|
||||
|
||||
```
|
||||
import_job(id, catalog, grant, imvu_product, kind ("import" | "recheck"), status ("queued" | "claimed" | "done" | "failed"),
|
||||
imvu_revision, reason, attempts, claimed_at, next_at, created_at, updated_at)
|
||||
```
|
||||
|
||||
- **Queued by the hub**: a request covered by a grant, a product added to a grant, a "shop" grant accepted (the
|
||||
products people requested so far, and all the shop's products if a way to list them is found), and a daily
|
||||
**recheck** of each imported product.
|
||||
- **The hub checks IMVU's public API** before queuing and at each recheck (rate-limited, as for parents): the
|
||||
product exists, its creator is the grant's account, its rating. Gone from IMVU: withdrawn. Rating changed to AP:
|
||||
adult at once. Retired (`is_purchasable` false): kept unless the creator excludes it.
|
||||
- **The importer claims jobs**, fetches, converts, uploads with a `t5de-import` token (products.md), and reports the
|
||||
outcome. Rechecks probe IMVU's CDN for revisions after the last imported one; a new one is imported.
|
||||
- Failures retry with backoff (1 min, 10 min, 1 h, then daily; failed for good after 5); the reason is shown in the
|
||||
console. The catalog's team can retry or re-import one product, and pause the importer (`import:pause`): the hub
|
||||
hands out no jobs while paused.
|
||||
|
||||
## Withdrawal
|
||||
|
||||
A withdrawn imported product (grant withdrawn or narrowed, gone from IMVU, account link moved) is removed as
|
||||
moderation removes products: it leaves libraries, outfits and rooms; its page says it was withdrawn; the catalog
|
||||
deletes its files (the catalog changes feed, products.md). Unlike deleting an uploaded product, people who had it
|
||||
don't keep it: the consent is gone.
|
||||
|
||||
## `t5de-importer`
|
||||
|
||||
A Go program (its own repository) beside a `t5de-catalog`; it stores nothing lasting but its working files.
|
||||
|
||||
- Configuration: `HUB_URL`, `CATALOG_ID`, `IMPORTER_KEY` (from enabling imports on the catalog), `IMPORTER_RATE`
|
||||
(requests a second to IMVU, default 2), optionally an IMVU account for exact revisions (`IMVU_USERNAME`,
|
||||
`IMVU_PASSWORD`; the operator's choice and risk; the hub never holds IMVU credentials).
|
||||
- Revisions: without an IMVU account, the CDN is probed (`HEAD productdata/<id>/<n>`) once per product; afterwards a
|
||||
recheck tries the next few numbers. With one, `authorizeProducts` gives exact revisions.
|
||||
- Reads CFLs with t5de-api's `cfl` package; the conversion follows `@imvu/chkn` (dhkatz/imvu.js) as a reference.
|
||||
- Validates with t5de-api's `pack` rules before uploading; a product breaking them fails with the reason. (The spike
|
||||
checks whether typical IMVU products fit; imports may need their own limits.)
|
||||
|
||||
## Hub routes
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/imports/catalogs` | approved catalogs with imports on, accepting grants |
|
||||
| `POST /api/v1/imports/grants` | `{catalog, imvuId, scope, products?, exclude?, listed, consentVersion}` → 201 `{grant}` (pending). With `onBehalf: {imvuId, evidence, proof?}` instead of a linked `imvuId`: an admin-recorded grant. |
|
||||
| `GET /api/v1/imports/grants` | the person's grants; with `catalog=`, a catalog's (its team) |
|
||||
| `PATCH /api/v1/imports/grants/:id` | `{scope?, products?, exclude?, listed?}` |
|
||||
| `POST /api/v1/imports/grants/:id/accept`, `.../decline` | the catalog's team |
|
||||
| `DELETE /api/v1/imports/grants/:id` | withdraw |
|
||||
| `POST /api/v1/imports/proofs` | (admins) `{imvuId}` → `{proof, code}`; `POST .../proofs/:id/check` |
|
||||
| `POST /api/v1/imports/requests` | `{imvuProduct}` or `{imvuCreator}` → `{status: "queued" \| "suggested" \| "counted", product?}` |
|
||||
| `GET /api/v1/imports/requests?imvuCreator=` | counts (the creator, admins) |
|
||||
| `GET /api/v1/imports/available?imvuProducts=a,b` | for the proxy's shop button: which can the person get (and the hub product ID), already have, or request |
|
||||
| `GET /api/v1/imports/jobs?catalog=&status=` | the catalog's team: the queue; `POST .../jobs/:id/retry` |
|
||||
| `POST /api/v1/imports/claim` | `{imvuId}`: claim an unclaimed creator's products for a linked account |
|
||||
|
||||
For importers (`Authorization: Importer <catalog ID>:<key>`) in products.md.
|
||||
|
||||
## Spike (before building the importer)
|
||||
|
||||
1. Import one product by hand as a local pack (full-size textures, no `_contents.json`, parent kept). Does the
|
||||
client wear it as a T5 product, with the parent loading from IMVU? (Never committed: it's IMVU's files.)
|
||||
2. Do typical IMVU products fit the `pack` limits?
|
||||
3. How many HEAD requests does probing take for real shops; does the CDN limit them?
|
||||
4. Is there a public way to list a creator's products (for importing a whole shop at once)?
|
||||
|
||||
## Later
|
||||
|
||||
- Flattened imports (parent included), if keeping the parent on IMVU causes problems.
|
||||
- A dry run: preview an import (and anything over the pack limits) before it's published.
|
||||
- "Get on T5DE" on IMVU's shop and product pages in the client (a t5de patch, using
|
||||
`/api/v1/imports/available`).
|
||||
- Creators who don't want their products as parents of imports (an opt-out hiding imports that derive from theirs).
|
||||
+106
-8
@@ -14,6 +14,17 @@ what each side sends.
|
||||
moderator approves it.
|
||||
- **Library**: the products a person "got" (free), per person (all their IMVU accounts), optionally staying on a
|
||||
revision.
|
||||
- **Draft**: a new product before its creator publishes it: only its creator and moderators see it; it can't be
|
||||
got, worn or placed. Drafts never published are deleted after 30 days.
|
||||
- **Unlisted**: a published product left out of search, lists and creator pages for others; anyone with its link
|
||||
can see and get it (membership rules apply).
|
||||
- **Member**: a person a catalog with approval or invite membership has let in. A product is **available** to a
|
||||
person if a catalog hosting it is approved, not suspended, and open or has them as a member (and hasn't banned
|
||||
them).
|
||||
- **Imported product**: a product a catalog's importer brought from IMVU with its creator's grant (imports.md).
|
||||
`imvuProduct` is set; otherwise it's like any product.
|
||||
|
||||
Who may do what (creators, catalog teams, moderators) is in `console.md` ("Permissions").
|
||||
|
||||
## Tokens
|
||||
|
||||
@@ -26,6 +37,7 @@ unknown `kid`). Claims always include `iss` (the hub's API URL), `typ` (below) a
|
||||
| `t5de-upload` | `sub` (person ID), `catalog` (catalog ID), `product` (optional: a new revision of this product) | 30 min | the creator's browser uploads to the catalog |
|
||||
| `t5de-access` | `sub` (person ID), `adult` (bool) | 1 h | the proxy fetches adult files from catalogs |
|
||||
| `t5de-revision` | `product`, `revision`, `rating`, `files` (the file list), `parent` (IMVU product ID or 0) | none (`exp` absent) | the hub vouches for a revision's files |
|
||||
| `t5de-import` | `sub` (the job ID), `catalog`, `imvuProduct`, `imvuRevision` | 30 min | an importer uploads an imported product to its catalog |
|
||||
|
||||
## Catalog API (`t5de-catalog`)
|
||||
|
||||
@@ -38,6 +50,14 @@ Served at the catalog's public URL. CORS: the hub website's origin may call `POS
|
||||
| `GET /api/v1/files/<sha256>` | the file's bytes, `Cache-Control: public, max-age=31536000, immutable` and `ETag` = the hash. A file used only by adult revisions needs `Authorization: Bearer <t5de-access token>` with `adult: true` (else 401/403, and `Cache-Control: private`). 404 if the catalog doesn't have it. |
|
||||
| `GET /api/v1/revisions/<product>/<revision>` | `{product, revision, rating, files}`, for revisions the catalog hosts |
|
||||
|
||||
Imports: `POST /api/v1/uploads` also takes `Authorization: Bearer <t5de-import token>` (from the catalog's
|
||||
importer). Then `notes` is optional, and the catalog registers the revision with `importToken` instead of
|
||||
`uploadToken`. Validation is the same.
|
||||
|
||||
Changes: the catalog polls the hub's changes feed (below) every minute and acts on it: deletes the files of
|
||||
revisions withdrawn or removed (files still used by another revision it hosts stay), and fetches revisions it's
|
||||
asked to mirror.
|
||||
|
||||
Configuration (environment): `CATALOG_ADDR` (listen address, default `127.0.0.1:4102`), `CATALOG_DATA` (data folder:
|
||||
SQLite database and files), `CATALOG_PUBLIC_URL`, `HUB_URL` (the hub's API), `CATALOG_ID` and `CATALOG_KEY` (from
|
||||
registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
|
||||
@@ -50,7 +70,21 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision}`. The upload token must be for this catalog. Without `product` in the token, a new product is made (owner: the token's person, name from `name`). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). |
|
||||
| `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision, draft}`. The upload token must be for this catalog. Without `product` in the token, a new product is made as a **draft** (owner: the token's person, name from `name`; the creator publishes it on the website). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). Files the hub blocks are refused (409 `blocked`). The catalog's upload policy (`anyone`, `members`, `team`) is checked when the upload token is made (403 `forbidden`). With `importToken` instead of `uploadToken`: an imported revision (imports.md); the hub checks the job and that a grant still covers it, makes the product (published, owned by the grant's creator) or adds the revision, and marks the job done. |
|
||||
| `GET /api/v1/catalog/changes?after=<cursor>` | `{changes, cursor}`: what the catalog must act on, oldest first: `{type: "delete", product, revision, files}` (withdrawn, removed by moderation, blocked: delete those files unless another hosted revision uses them), `{type: "mirror", product, revision, files, from: [catalog URLs]}` (fetch each file from one of `from`, check its sha256, then confirm below), `{type: "status", status}` (the catalog was suspended or reinstated), `{type: "block", sha256}` (moderation blocked the file: delete it whatever uses it, and refuse it in uploads). Start with no `after`; keep the cursor. |
|
||||
| `POST /api/v1/catalog/hosting` | `{product, revision}`: the catalog now hosts a revision it was asked to mirror (it has every file) → 204 |
|
||||
| `POST /api/v1/catalog/stats` | optional: `{day, downloads, bytes}` for a past day |
|
||||
|
||||
### For importers (server to server)
|
||||
|
||||
`Authorization: Importer <catalog ID>:<key>` (the key from enabling imports on the catalog; rotated like the
|
||||
catalog's). Only approved catalogs with imports on and not paused.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/importer/jobs/claim` | `{limit}` (1-20) → `{jobs: [{id, kind, imvuProduct, lastRevision}]}`; claimed jobs not reported within 30 minutes go back to the queue |
|
||||
| `POST /api/v1/importer/jobs/:id/token` | `{imvuRevision}` → `{token, url}`: a `t5de-import` token and the catalog's upload URL |
|
||||
| `POST /api/v1/importer/jobs/:id` | `{status: "done" \| "failed" \| "unchanged", imvuRevision?, reason?}` |
|
||||
|
||||
### For people (website, signed in; the proxy with its bearer token)
|
||||
|
||||
@@ -58,13 +92,41 @@ 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/: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 |
|
||||
| `GET /api/v1/catalogs/:id` | (anyone, if public or they're a member or on its team) `{catalog, membership?: {status}, permissions}` |
|
||||
| `PATCH /api/v1/catalogs/:id` | (`catalog:edit`) `{name?, url?, contact?, visibility?, membership?, maxRating? ("general"/"adult"), uploads? ("anyone"/"members"/"team"), mirrors? ("auto"/"approve"/"off")}`. A new `url` must answer `/health`. |
|
||||
| `POST /api/v1/catalogs/:id/key` | (`catalog:rotate-key`) → `{key}`: a new key; the old one stops working at once |
|
||||
| `POST /api/v1/catalogs/:id/importer` | (`import:run`) `{enabled}` → `{key?}`: turns imports on (a new importer key, shown once) or off; `POST .../importer/key` rotates it; `{paused}` pauses |
|
||||
| `GET /api/v1/catalogs/:id/team`, `PUT/DELETE .../team/:person` | (`team:manage`) `{role: "operator"}` |
|
||||
| `POST /api/v1/catalogs/:id/transfer` | (`catalog:transfer`) `{to}`: offered to the person, who accepts on their side (`POST .../transfer/accept`) |
|
||||
| `POST /api/v1/catalogs/:id/delete` | (`catalog:delete-request`) `{reason}` → `{case}`: opens a `catalog-deletion` case (409 `requested` if one is open); nothing changes until a moderator decides (console.md). Resolved, the catalog's status is `deleted`: nothing is available from it, and its key stops working |
|
||||
| `POST /api/v1/catalogs/:id/join` | join an open catalog, or ask to join an approval one; `{invite}` to use an invite code. `DELETE` leaves. |
|
||||
| `GET /api/v1/catalogs/:id/members?status=` | (`member:approve`) members, requests and bans |
|
||||
| `PUT /api/v1/catalogs/:id/members/:person` | `{status: "member" \| "banned"}` (`member:approve` / `member:ban`); `DELETE` removes (`member:remove`) |
|
||||
| `POST /api/v1/catalogs/:id/invites` | (`member:invite`) `{maxUses?, expiresIn?}` → `{invite: {code, url}}`; `GET` lists, `DELETE .../invites/:code` revokes |
|
||||
| `GET /api/v1/catalogs/:id/products` | the products it hosts (its team also sees drafts' counts, delisted ones) |
|
||||
| `DELETE /api/v1/catalogs/:id/products/:product` | (`hosting:delist`) `{reason}`: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it. |
|
||||
| `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why |
|
||||
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) |
|
||||
| `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. `category`: an IMVU category ID in its category path (its own if chosen, else its latest revision's parent's; e.g. 106 Clothing, 128 Tops). `tag`: one tag; `catalog`: those the catalog hosts. `limit`: 1-200, default 60 |
|
||||
| `GET /api/v1/products/:id` | (anyone, adult rule as above, except that its creator and moderators always see it; drafts only for their creator and moderators; getting and wearing adult products still need an adult account) `{product, revisions, inLibrary?, mine?, available, permissions}` |
|
||||
| `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, category?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. `category`: the ID of one of its category choices (below), or null for its parent's. Rating may go general → adult; adult → general without `product:rate-lower` opens a `rating-review` case for moderators instead (one at a time; its ID is the answer's `review`; the other fields still change), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. |
|
||||
| `GET /api/v1/products/:id/categories` | (`product:edit`) `{choices, parent}`: the category paths it may be filed under, and its parent's. The choices are every IMVU category path the hub has seen on a revision's parent, and each step of them, under the same top-level category as its parent's (any, if that's unknown). Keeping the top level keeps it the same kind of product: the proxy tells clothing, furniture and rooms apart by category. IMVU's own category tree would replace the seen paths if one turns up. |
|
||||
| `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published (409 `published` if it is already) |
|
||||
| `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` |
|
||||
| `POST /api/v1/products/:id/history/:entry/revert` | (`product:edit`) `{reason?}`: puts back what an edit (or revert) changed, as a new `revert` entry, by the same rules as an edit (locks, reasons, ratings); 409 `unchanged` if the info is already as it was |
|
||||
| `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}`: the fields locked from now on (any of name, description, rating, tags, listed, category; the rest are unlocked). `reason` required; kept in the history |
|
||||
| `POST /api/v1/products/:id/transfer` | (`product:transfer`) `{to}`; the other person accepts (`.../transfer/accept`) |
|
||||
| `POST /api/v1/products/:id/restore` | (`product:restore`) undo a creator's delete |
|
||||
| `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) |
|
||||
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
|
||||
| `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/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 (until restored). 204 |
|
||||
| `POST /api/v1/products/:id/remove` | (`product:remove`) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, it leaves libraries and rooms, and catalogs delete its files. `.../unremove` (`product:unremove`) `{reason?}` undoes it (for now removal only hides it, so its files and library entries come back). Its creator is told both. |
|
||||
| `GET /api/v1/blocks?before=` | (`product:remove` hub-wide) `{blocks: [{sha256, reason, by, at}], before}`, newest first, 50 a page |
|
||||
| `GET /api/v1/blocks/:sha256` | (`product:remove`) `{block (or null), products}`: the products with a revision using the file (`{id, name, creatorId, creatorUsername, removed, revisions}`), what blocking it removes |
|
||||
| `PUT /api/v1/blocks/:sha256` | (`product:remove`) `{reason}` → `{removed: [{id, name}]}`: blocks a file network-wide: revisions with it can't be registered (409 `blocked`; the attempt is audited as `file.blocked_upload`, with the person and catalog), products using it are removed (each audited as `product.remove` with the `file`, so its creator is told and may appeal), and every catalog hosting it gets a `block` change. 409 `blocked` if it is already. |
|
||||
| `DELETE /api/v1/blocks/:sha256` | (`product:remove`) `{reason?}` → 204: unblocks it; the products it removed stay removed until restored (`.../unremove`). |
|
||||
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products that are available to them, each with the revision to use (products no longer available, e.g. after leaving a catalog, stay in the table but aren't listed until they are again) |
|
||||
| `PUT /api/v1/library/:id` | get a product (add it): it must be published and available to the person (403 `members_only`, with the catalogs they could join) |
|
||||
| `DELETE /api/v1/library/:id` | remove it |
|
||||
| `PATCH /api/v1/library/:id` | `{stayOn: n or null}` |
|
||||
| `GET /api/v1/products/:id/files` | for the proxy: `{product, revision, rating, parent, name, files, catalogs: [url], token}` for the revision this person uses (their pin, else the latest that isn't retracted); `token` is the `t5de-revision` token |
|
||||
@@ -72,6 +134,23 @@ 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, category, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt,
|
||||
imvuProduct, createdAt, updatedAt}` (`imvuProduct` comes with imports). The product's page also has `permissions`: what the
|
||||
person may do to it (the website shows actions by them). `category` is the path chosen for it (null: its parent's), and `categories` the one it's filed under (`category` if
|
||||
chosen); `parent`, `image` and the parent's 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`.
|
||||
@@ -83,5 +162,24 @@ The realtime connection also tells the proxy `{"type": "library"}` when the pers
|
||||
adult revisions), checks each sha256, and writes the product as a pack in its cache
|
||||
(`%APPDATA%\T5DE\proxy\hub-products\<id>\`: `t5de-product.json` with the hub's name and rating, and `files/`).
|
||||
From there it's served like any pack.
|
||||
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client.
|
||||
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client. Seeing
|
||||
needs no membership (design.md, "Seeing versus wearing"): `/files` answers for any published product the person
|
||||
may see by rating. Wearing does: the hub relays a person's outfit with only the products available to them.
|
||||
- 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");
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user