Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9c2f32cb | ||
|
|
2b88a52bcb | ||
|
|
58a3e70a63 | ||
|
|
7299e10b08 | ||
|
|
e07a6c6dac | ||
|
|
2cbedd26fc | ||
|
|
ed3a188b82 | ||
|
|
d5cd2be5d0 | ||
|
|
86ebe976ba | ||
|
|
efd5d52198 | ||
|
|
f8ab3f965e |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
${PullRequestTitle} (#${PullRequestIndex})
|
||||
|
||||
${PullRequestDescription}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+1478
-75
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;
|
||||
}
|
||||
}
|
||||
+52
-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,17 +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, products } = 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; revisions missing their
|
||||
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
|
||||
// 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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
+432
-29
@@ -20,12 +20,38 @@ 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(
|
||||
@@ -37,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("");
|
||||
@@ -50,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;
|
||||
@@ -74,6 +124,21 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
|
||||
// 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));
|
||||
|
||||
@@ -123,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;
|
||||
}
|
||||
|
||||
@@ -163,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 } : {}) },
|
||||
@@ -183,9 +266,20 @@ 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)));
|
||||
|
||||
const made = await this.db.transaction(async (tx) => {
|
||||
let p: Product;
|
||||
@@ -204,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!;
|
||||
}
|
||||
@@ -230,7 +324,7 @@ 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(() => {});
|
||||
@@ -318,17 +412,51 @@ export class Products {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
|
||||
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
||||
* 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; adult: boolean; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
|
||||
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`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
|
||||
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({
|
||||
@@ -355,7 +483,7 @@ export class Products {
|
||||
return r;
|
||||
}
|
||||
|
||||
async get(id: string, adult: boolean) {
|
||||
async get(id: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const [row] = await this.db
|
||||
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
|
||||
.from(product)
|
||||
@@ -363,7 +491,10 @@ export class Products {
|
||||
.where(eq(product.id, id));
|
||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
// 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)
|
||||
@@ -377,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();
|
||||
return updated!;
|
||||
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) {
|
||||
@@ -427,7 +807,7 @@ export class Products {
|
||||
.innerJoin(product, eq(product.id, library.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt), availableTo(personId)))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -439,13 +819,35 @@ export class Products {
|
||||
return row;
|
||||
}
|
||||
|
||||
async add(personId: string, productId: string, adult: boolean) {
|
||||
const p = 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)));
|
||||
}
|
||||
@@ -470,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);
|
||||
|
||||
@@ -121,8 +121,15 @@ export type RealtimeDeps = {
|
||||
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}$/;
|
||||
@@ -179,6 +186,7 @@ export class Realtime {
|
||||
hostedBoot: async () => {},
|
||||
hostedVisit: () => {},
|
||||
accountName: async () => "",
|
||||
wearable: async (_, ids) => new Set(ids),
|
||||
...d,
|
||||
};
|
||||
}
|
||||
@@ -644,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+146
-7
@@ -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();
|
||||
@@ -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,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"]);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -122,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" });
|
||||
@@ -147,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);
|
||||
|
||||
@@ -174,14 +186,19 @@ 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([]);
|
||||
@@ -230,6 +247,7 @@ test("derived products carry their IMVU parent's image and categories", async ()
|
||||
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);
|
||||
@@ -266,6 +284,28 @@ test("derived products carry their IMVU parent's image and categories", async ()
|
||||
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 });
|
||||
@@ -287,6 +327,7 @@ test("creators delete products (those who have them keep them); moderation remov
|
||||
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);
|
||||
@@ -313,3 +354,109 @@ test("creators delete products (those who have them keep them); moderation remov
|
||||
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");
|
||||
});
|
||||
@@ -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,29 +55,31 @@ export default async function ConsoleCatalogs() {
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status !== "approved" && (
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
)}
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
{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">
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -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
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
{tabs
|
||||
.filter((t) => sections.has(t.section))
|
||||
.map((t) => (
|
||||
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+104
-65
@@ -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>
|
||||
<CardDescription>
|
||||
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
|
||||
the page loaded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>Online now</CardTitle>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||
{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>
|
||||
<OnlineList initial={data.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit log</CardTitle>
|
||||
<CardDescription>The latest 50 changes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditLog entries={data.audit} />
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<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>
|
||||
))}
|
||||
</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">
|
||||
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
|
||||
<Button type="submit" variant="outline">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+14
-2
@@ -1,7 +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";
|
||||
|
||||
@@ -13,13 +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}`}>
|
||||
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
|
||||
Back to the product
|
||||
</Link>
|
||||
<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">
|
||||
{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"
|
||||
/>
|
||||
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
|
||||
{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">
|
||||
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||
<NativeSelectOption value="adult">Adult</NativeSelectOption>
|
||||
<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>
|
||||
)}
|
||||
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
|
||||
{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>
|
||||
)}
|
||||
{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,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, ProductImage, 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";
|
||||
@@ -19,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">
|
||||
@@ -28,10 +30,30 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{product.creator && <span>by {product.creator}</span>}
|
||||
<RatingTag rating={product.rating} />
|
||||
{product.deleted && <Tag tone="danger">deleted</Tag>}
|
||||
{/* 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.
|
||||
@@ -51,8 +73,20 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
</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">
|
||||
{product.deleted && !inLibrary ? null : 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 ? (
|
||||
@@ -62,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>
|
||||
@@ -74,11 +122,13 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Sign in to get it
|
||||
</Link>
|
||||
)}
|
||||
{permissions.includes("product:edit") && !(mine && product.deleted) && (
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
{mine ? "Edit" : "Edit info"}
|
||||
</Link>
|
||||
)}
|
||||
{mine && !product.deleted && (
|
||||
<>
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
Edit
|
||||
</Link>
|
||||
<ActionForm action={deleteProduct} fields={{ id }}>
|
||||
<SubmitButton
|
||||
variant="outline"
|
||||
@@ -101,6 +151,8 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{me && !mine && !draft && <ReportForm about="product" id={id} />}
|
||||
|
||||
{isModerator(me) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -137,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>
|
||||
@@ -160,6 +213,7 @@ function RevisionRow({
|
||||
stayOn,
|
||||
using,
|
||||
mine,
|
||||
canBlock,
|
||||
}: {
|
||||
id: string;
|
||||
revision: Revision;
|
||||
@@ -168,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),
|
||||
@@ -195,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>
|
||||
|
||||
@@ -56,6 +56,18 @@ export async function removeProduct(_: ActionState, form: FormData): Promise<Act
|
||||
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 () => {
|
||||
@@ -64,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,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function NewProduct() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="New product">
|
||||
Upload it to a catalog. You can edit its info after.{" "}
|
||||
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>
|
||||
|
||||
@@ -8,15 +8,29 @@ import { api, getMe, unwrap } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "Products" };
|
||||
|
||||
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||
const raw = (await searchParams).q;
|
||||
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
|
||||
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]);
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader title="Products">Community products for T5DE.</PageHeader>
|
||||
<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
|
||||
@@ -25,12 +39,14 @@ export default async function Products({ searchParams }: { searchParams: Promise
|
||||
</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) => (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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, ProfileMenu } from "./header-client";
|
||||
import logo from "./t5de.png";
|
||||
@@ -24,8 +25,25 @@ export async function Header() {
|
||||
<NavLink href="/catalogs">Catalogs</NavLink>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
<a href="https://t5de.dev/download" className={buttonVariants({ variant: "ghost" })}>
|
||||
Download
|
||||
</a>
|
||||
{me ? (
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||
|
||||
@@ -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`;
|
||||
@@ -39,7 +56,7 @@ export function ProductCard({
|
||||
product,
|
||||
children,
|
||||
}: {
|
||||
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories">;
|
||||
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories" | "listed">;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const category = product.categories.at(-1)?.name;
|
||||
@@ -51,7 +68,10 @@ export function ProductCard({
|
||||
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||
{product.name}
|
||||
</Link>
|
||||
<RatingTag rating={product.rating} />
|
||||
<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}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -37,12 +37,26 @@ 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];
|
||||
|
||||
+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 }
|
||||
|
||||
+32
-12
@@ -7,7 +7,9 @@ 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`,
|
||||
@@ -17,10 +19,11 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
|
||||
| `POST /api/auth/sign-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}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
|
||||
| `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
|
||||
|
||||
@@ -86,9 +89,11 @@ sha256 (hex) of `contents` as JSON.
|
||||
## Products, catalogs and libraries
|
||||
|
||||
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
||||
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the
|
||||
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
|
||||
category ID) and `limit` (1-200, default 60).
|
||||
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
|
||||
|
||||
@@ -97,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.
|
||||
+62
-5
@@ -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)
|
||||
|
||||
@@ -329,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)
|
||||
|
||||
@@ -431,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.
|
||||
|
||||
+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).
|
||||
+76
-11
@@ -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,15 +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=&category=&limit=` | (anyone) `{products}`, newest first; adult ones only for people with an adult account. `category`: an IMVU category ID; only products whose latest revision's parent has it anywhere in its category path (e.g. 106 Clothing, 128 Tops). `limit`: 1-200, default 60 |
|
||||
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
|
||||
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
|
||||
| `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) |
|
||||
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after. 204 |
|
||||
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
|
||||
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
|
||||
| `PUT /api/v1/library/:id` | get a product (add it) |
|
||||
| `DELETE /api/v1/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 |
|
||||
@@ -75,7 +135,10 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
|
||||
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes.
|
||||
|
||||
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
|
||||
parent, image, categories, createdAt, updatedAt}`. `parent`, `image` and `categories` are the latest revision's:
|
||||
parent, 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>`).
|
||||
@@ -99,7 +162,9 @@ every 10 minutes, 20 at a time.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "hub_setting" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "approved" boolean DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE "catalog_change" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"catalog_id" text NOT NULL,
|
||||
"data" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "catalog_invite" (
|
||||
"code" text PRIMARY KEY NOT NULL,
|
||||
"catalog_id" text NOT NULL,
|
||||
"created_by" text,
|
||||
"max_uses" integer,
|
||||
"uses" integer DEFAULT 0 NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "catalog_member" (
|
||||
"catalog_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"invite" text,
|
||||
"decided_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "catalog_member_catalog_id_user_id_pk" PRIMARY KEY("catalog_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "role_assignment" (
|
||||
"user_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" text NOT NULL,
|
||||
"granted_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "role_assignment_user_id_role_scope_type_scope_id_pk" PRIMARY KEY("user_id","role","scope_type","scope_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audit" ADD COLUMN "tags" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "status_reason" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "max_rating" text DEFAULT 'adult' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "uploads" text DEFAULT 'anyone' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "mirrors" text DEFAULT 'approve' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_change" ADD CONSTRAINT "catalog_change_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_granted_by_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "catalog_change_catalog" ON "catalog_change" USING btree ("catalog_id","id");--> statement-breakpoint
|
||||
CREATE INDEX "catalog_invite_catalog" ON "catalog_invite" USING btree ("catalog_id");--> statement-breakpoint
|
||||
CREATE INDEX "catalog_member_user" ON "catalog_member" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "role_assignment_scope" ON "role_assignment" USING btree ("scope_type","scope_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_tags" ON "audit" USING gin ("tags");--> statement-breakpoint
|
||||
-- Tags for entries written before tags existed (target was the catalog, product or room).
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('catalog', "target") WHERE "action" LIKE 'catalog.%' AND "target" <> '';--> statement-breakpoint
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('product', "target") WHERE "action" LIKE 'product.%' AND "target" <> '';--> statement-breakpoint
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('room', "target") WHERE "action" LIKE 'hosted_room.%' AND "target" <> '';
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "product_info_change" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"product_id" text NOT NULL,
|
||||
"by_id" text,
|
||||
"kind" text NOT NULL,
|
||||
"changes" jsonb NOT NULL,
|
||||
"reason" text DEFAULT '' NOT NULL,
|
||||
"revert_of" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "status" text DEFAULT 'published' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "listed" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "category" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "locked" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "product_info_change_product" ON "product_info_change" USING btree ("product_id","id");--> statement-breakpoint
|
||||
CREATE INDEX "product_tags" ON "product" USING gin ("tags");--> statement-breakpoint
|
||||
-- Products from before drafts were published when made.
|
||||
UPDATE "product" SET "published_at" = "created_at" WHERE "published_at" IS NULL;
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE "case_note" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"case_id" integer NOT NULL,
|
||||
"by_id" text,
|
||||
"body" text NOT NULL,
|
||||
"internal" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "moderation_case" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"opened_by" text,
|
||||
"assignee" text,
|
||||
"reason" text DEFAULT '' NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"resolution" text DEFAULT '' NOT NULL,
|
||||
"decided_by" text,
|
||||
"decided_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"read_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "case_note" ADD CONSTRAINT "case_note_case_id_moderation_case_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."moderation_case"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "case_note" ADD CONSTRAINT "case_note_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_opened_by_user_id_fk" FOREIGN KEY ("opened_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_assignee_user_id_fk" FOREIGN KEY ("assignee") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "case_note_case" ON "case_note" USING btree ("case_id","id");--> statement-breakpoint
|
||||
CREATE INDEX "moderation_case_status" ON "moderation_case" USING btree ("status","id");--> statement-breakpoint
|
||||
CREATE INDEX "moderation_case_opened_by" ON "moderation_case" USING btree ("opened_by");--> statement-breakpoint
|
||||
CREATE INDEX "moderation_case_tags" ON "moderation_case" USING gin ("tags");--> statement-breakpoint
|
||||
CREATE INDEX "notification_user" ON "notification" USING btree ("user_id","id");
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "file_block" (
|
||||
"sha256" text PRIMARY KEY NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"by_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "file_block" ADD CONSTRAINT "file_block_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "product_revision_files" ON "product_revision" USING gin ("files");
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "api_token" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"hash" text NOT NULL,
|
||||
"grants" jsonb NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_token" ADD CONSTRAINT "api_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "api_token_hash" ON "api_token" USING btree ("hash");--> statement-breakpoint
|
||||
CREATE INDEX "api_token_user" ON "api_token" USING btree ("user_id");
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -64,6 +64,48 @@
|
||||
"when": 1793372872749,
|
||||
"tag": "0008_product_deletion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1793707706030,
|
||||
"tag": "0009_registration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1793862017365,
|
||||
"tag": "0010_catalog_management",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1793886718951,
|
||||
"tag": "0011_product_info",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1793888724185,
|
||||
"tag": "0012_cases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1793917069600,
|
||||
"tag": "0013_file_blocks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1793918993367,
|
||||
"tag": "0014_api_tokens",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem, TokenGrant } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
+220
-4
@@ -43,6 +43,8 @@ export const user = pgTable(
|
||||
banExpires: timestamp("ban_expires", { withTimezone: true }),
|
||||
// How many IMVU accounts the person may link; moderators raise it on request.
|
||||
linkCap: integer("link_cap").notNull().default(10),
|
||||
// False while a new account waits for a moderator's approval (registration closed); it can only sign in.
|
||||
approved: boolean("approved").notNull().default(true),
|
||||
},
|
||||
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
|
||||
);
|
||||
@@ -219,10 +221,15 @@ export const audit = pgTable(
|
||||
action: text("action").notNull(),
|
||||
target: text("target").notNull().default(""),
|
||||
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
|
||||
// The resources the change touches ({catalog, product, room, person}): who may see the entry (docs/console.md).
|
||||
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
|
||||
},
|
||||
(t) => [index("audit_at").on(t.at)],
|
||||
(t) => [index("audit_at").on(t.at), index("audit_tags").using("gin", t.tags)],
|
||||
);
|
||||
|
||||
/** The resources an audit entry touches. */
|
||||
export type AuditTags = { catalog?: string; product?: string; room?: string; person?: string };
|
||||
|
||||
// ---- Products (docs/products.md) ----
|
||||
|
||||
export type FileEntry = { name: string; sha256: string; size: number };
|
||||
@@ -257,13 +264,89 @@ export const catalog = pgTable(
|
||||
contact: text("contact").notNull(), // takedown and legal contact
|
||||
visibility: text("visibility").notNull().default("public"), // "public", "private"
|
||||
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected", "suspended"
|
||||
statusReason: text("status_reason").notNull().default(""), // why it was rejected or suspended
|
||||
maxRating: text("max_rating").notNull().default("adult"), // the highest rating it hosts: "general", "adult"
|
||||
uploads: text("uploads").notNull().default("anyone"), // who may upload: "anyone", "members", "team"
|
||||
mirrors: text("mirrors").notNull().default("approve"), // mirroring others' revisions: "auto", "approve", "off"
|
||||
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("catalog_url").on(t.url)],
|
||||
);
|
||||
|
||||
// A role given to a person at a scope (docs/console.md). Hub-wide roles are user.role; owners are derived (a
|
||||
// catalog's owner_id, a product's creator_id), so they're never here.
|
||||
export const roleAssignment = pgTable(
|
||||
"role_assignment",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(), // e.g. "catalog-operator"
|
||||
scopeType: text("scope_type").notNull(), // "catalog"
|
||||
scopeId: text("scope_id").notNull(),
|
||||
grantedBy: text("granted_by").references(() => user.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.role, t.scopeType, t.scopeId] }),
|
||||
index("role_assignment_scope").on(t.scopeType, t.scopeId),
|
||||
],
|
||||
);
|
||||
|
||||
// A person's membership of a catalog: asked to join, a member, or banned from it.
|
||||
export const catalogMember = pgTable(
|
||||
"catalog_member",
|
||||
{
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "requested", "member", "banned"
|
||||
invite: text("invite"), // the invite code they joined with
|
||||
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.catalogId, t.userId] }), index("catalog_member_user").on(t.userId)],
|
||||
);
|
||||
|
||||
// An invitation to join a catalog: a code, used up to maxUses times (null: no limit) until it expires.
|
||||
export const catalogInvite = pgTable(
|
||||
"catalog_invite",
|
||||
{
|
||||
code: text("code").primaryKey(),
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }),
|
||||
maxUses: integer("max_uses"),
|
||||
uses: integer("uses").notNull().default(0),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("catalog_invite_catalog").on(t.catalogId)],
|
||||
);
|
||||
|
||||
// What a catalog must act on (GET /api/v1/catalog/changes): files to delete, revisions to mirror, its status.
|
||||
export const catalogChange = pgTable(
|
||||
"catalog_change",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull(), // {type, ...}
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("catalog_change_catalog").on(t.catalogId, t.id)],
|
||||
);
|
||||
|
||||
// A product: its info, owned by its creator. Its files are in revisions.
|
||||
export const product = pgTable(
|
||||
"product",
|
||||
@@ -276,6 +359,16 @@ export const product = pgTable(
|
||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
||||
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||
// "draft" (new uploads: only its creator and moderators see it) or "published".
|
||||
status: text("status").notNull().default("published"),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
// Unlisted: left out of search, lists and creator pages for others; its link still works.
|
||||
listed: boolean("listed").notNull().default(true),
|
||||
tags: jsonb("tags").$type<string[]>().notNull().default([]),
|
||||
// The IMVU category path its creator (or a moderator) chose, outermost first; null: its parent's.
|
||||
category: jsonb("category").$type<{ id: number; name: string }[]>(),
|
||||
// Info fields a moderator locked: only people with product:edit-locked change them.
|
||||
locked: jsonb("locked").$type<string[]>().notNull().default([]),
|
||||
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
|
||||
@@ -284,7 +377,32 @@ export const product = pgTable(
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
|
||||
(t) => [
|
||||
index("product_creator").on(t.creatorId),
|
||||
index("product_created").on(t.createdAt),
|
||||
index("product_tags").using("gin", t.tags),
|
||||
],
|
||||
);
|
||||
|
||||
/** One field's change in a product's info history. */
|
||||
export type InfoChange = { from: unknown; to: unknown };
|
||||
|
||||
// A product's info history: every edit, revert, lock and publish, with who and why (docs/products.md).
|
||||
export const productInfoChange = pgTable(
|
||||
"product_info_change",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
|
||||
kind: text("kind").notNull(), // "edit", "revert", "lock", "publish"
|
||||
changes: jsonb("changes").$type<Record<string, InfoChange>>().notNull(),
|
||||
reason: text("reason").notNull().default(""),
|
||||
revertOf: integer("revert_of"), // the entry a revert undid
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("product_info_change_product").on(t.productId, t.id)],
|
||||
);
|
||||
|
||||
// A revision: a product's files. Never changes (but can be retracted).
|
||||
@@ -305,9 +423,18 @@ export const productRevision = pgTable(
|
||||
retracted: boolean("retracted").notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.productId, t.number] })],
|
||||
(t) => [primaryKey({ columns: [t.productId, t.number] }), index("product_revision_files").using("gin", t.files)],
|
||||
);
|
||||
|
||||
// A file blocked network-wide by its sha256: revisions with it can't be registered, products using it are removed,
|
||||
// and catalogs are told to delete it (docs/products.md).
|
||||
export const fileBlock = pgTable("file_block", {
|
||||
sha256: text("sha256").primaryKey(),
|
||||
reason: text("reason").notNull(),
|
||||
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
// Which catalogs host which revisions.
|
||||
export const hosting = pgTable(
|
||||
"hosting",
|
||||
@@ -473,3 +600,92 @@ export const roomDecoration = pgTable(
|
||||
},
|
||||
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
|
||||
);
|
||||
|
||||
// The hub's settings that admins change from the console (e.g. registration: "open" or "closed").
|
||||
export const hubSetting = pgTable("hub_setting", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
// A case: something someone has to decide (docs/console.md, "Cases"): a report, a rating review, a removal request,
|
||||
// a catalog's deletion request, or an appeal. ("case" is an SQL keyword, hence the table's name.)
|
||||
export const moderationCase = pgTable(
|
||||
"moderation_case",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
kind: text("kind").notNull(), // "report", "rating-review", "removal-request", "catalog-deletion", "appeal"
|
||||
status: text("status").notNull().default("open"), // "open", "resolved", "declined"
|
||||
// What it's about ({catalog, product, room, person}), as audit entries are tagged.
|
||||
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
|
||||
openedBy: text("opened_by").references(() => user.id, { onDelete: "set null" }),
|
||||
assignee: text("assignee").references(() => user.id, { onDelete: "set null" }),
|
||||
reason: text("reason").notNull().default(""), // why it was opened
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}), // per kind (an appeal's audit entry)
|
||||
resolution: text("resolution").notNull().default(""),
|
||||
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
|
||||
decidedAt: timestamp("decided_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("moderation_case_status").on(t.status, t.id),
|
||||
index("moderation_case_opened_by").on(t.openedBy),
|
||||
index("moderation_case_tags").using("gin", t.tags),
|
||||
],
|
||||
);
|
||||
|
||||
// A case's thread. Internal notes are only for those deciding it.
|
||||
export const caseNote = pgTable(
|
||||
"case_note",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
caseId: integer("case_id")
|
||||
.notNull()
|
||||
.references(() => moderationCase.id, { onDelete: "cascade" }),
|
||||
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
|
||||
body: text("body").notNull(),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("case_note_case").on(t.caseId, t.id)],
|
||||
);
|
||||
|
||||
// Something for a person to know: a moderator edited their product, their case was decided, ... (docs/console.md).
|
||||
export const notification = pgTable(
|
||||
"notification",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: createdAt(),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [index("notification_user").on(t.userId, t.id)],
|
||||
);
|
||||
|
||||
/** What an API token may do: a permission at a scope (`{type: "hub"}`, or a catalog or product by ID). */
|
||||
export type TokenGrant = { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } };
|
||||
|
||||
// Personal API tokens for scripts (docs/console.md): a subset of the person's permissions; only the hash is kept.
|
||||
export const apiToken = pgTable(
|
||||
"api_token",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
hash: text("hash").notNull(), // sha256 of the token, hex
|
||||
grants: jsonb("grants").$type<TokenGrant[]>().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [uniqueIndex("api_token_hash").on(t.hash), index("api_token_user").on(t.userId)],
|
||||
);
|
||||
Reference in new issue
Block a user