Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s

The hub registers catalogs (hidden until a moderator approves them), hands out upload tokens, and records the
revisions catalogs register after validating uploads: numbered, never changed, signed by the hub (its own
Ed25519 key, published at /.well-known/t5de-keys.json). Creators edit product info (descriptions cleaned by an
allowlist) and can retract revisions. People get products into their library, optionally staying on a
revision; the proxy asks which revision to load and where. Contract in docs/products.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:53:52 -07:00
1 parent 7fea63fc4f
commit b4f6fce9df
15 files changed
+2883 -14

No files matched your search

+4 -1
View File
@@ -14,10 +14,13 @@
"dependencies": {
"@t5de/db": "workspace:*",
"better-auth": "1.7.14",
"elysia": "1.4.30"
"elysia": "1.4.30",
"jose": "6.2.14",
"sanitize-html": "2.18.1"
},
"devDependencies": {
"@types/bun": "^1.4.4",
"@types/sanitize-html": "^2.16.2",
"typescript": "^7.0.2"
}
}
+263 -11
View File
@@ -9,10 +9,21 @@ import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms };
export type Deps = {
db: Database;
auth: Auth;
imvu: ImvuClient;
realtime?: Realtime;
age?: AgeService;
rooms?: Rooms;
/** The hub's API URL, the issuer of its tokens. */
apiURL?: string;
};
/** An error shown to the person: the website displays message as is. */
export class Problem extends Error {
@@ -62,8 +73,10 @@ class Limiter {
}
}
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) {
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer);
const rooms = roomsService ?? new Rooms(db, imvu);
const realtime =
rt ??
@@ -148,6 +161,52 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) });
/** Whether any of a person's IMVU accounts counts as adult (for seeing adult products and rooms). */
const personAdult = async (p: Person | undefined) => {
if (!p) return false;
const ids = (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id);
return ids.length > 0 && (await age.adultAccounts(p.id, ids)).size > 0;
};
/** Runs a product operation, turning its errors into Problems. */
const productCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
const productView = (
p: typeof schema.product.$inferSelect,
creator?: string | null,
creatorUsername?: string | null,
) => ({
id: p.id,
name: p.name,
description: p.description,
rating: p.rating as "general" | "adult",
latest: p.latest,
creator: creator ?? creatorUsername ?? "",
creatorUsername: creatorUsername ?? "",
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
});
const catalogView = (c: typeof schema.catalog.$inferSelect) => ({
id: c.id,
name: c.name,
url: c.url,
contact: c.contact,
visibility: c.visibility,
membership: c.membership,
status: c.status,
createdAt: c.createdAt.toISOString(),
});
const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const ownLink = async (p: Person, imvuId: number) => {
const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id)));
if (!l) throw new Problem(404, "not_found", "That account isn't linked to you.");
@@ -470,14 +529,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
.get("/rooms", async ({ person: me }) => {
const viewerAdult =
me !== undefined &&
(
await age.adultAccounts(
me.id,
(await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id),
)
).size > 0;
const viewerAdult = await personAdult(me);
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
const here = new Map<string, number[]>();
for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]);
@@ -508,6 +560,189 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
return { rooms: out };
})
// Catalogs: registering one, and the directory.
.post(
"/catalogs",
async ({ me, body, set }) => {
const r = await productCall(() => products.registerCatalog(me.id, body));
await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url });
set.status = 201;
return { catalog: catalogView(r.catalog), key: r.key };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 64 }),
url: t.String({ maxLength: 300 }),
contact: t.String({ maxLength: 200 }),
visibility: t.String(),
membership: t.String(),
}),
},
)
.get("/catalogs", async ({ person }) => ({
catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })),
}))
// For catalogs: registering an uploaded revision.
.post(
"/catalog/revisions",
async ({ request, body, set }) => {
const c = await products.catalogFor(request.headers.get("authorization"));
if (!c) throw new Problem(401, "forbidden", "Not an approved catalog.");
const r = await productCall(() => products.registerRevision(c, body));
set.status = 201;
return r;
},
{
body: t.Object({
uploadToken: t.String({ maxLength: 4000 }),
files: t.Array(t.Object({ name: t.String(), sha256: t.String(), size: t.Number() }), { maxItems: 512 }),
rating: t.String(),
parent: t.Number(),
name: t.String({ maxLength: 200 }),
notes: t.String({ maxLength: 4000 }),
}),
},
)
// Products.
.post(
"/products/upload",
async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)),
{ signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) },
)
.get(
"/products",
async ({ person, query }) => {
const adult = await personAdult(person);
const rows = await products.list({ q: query.q, creator: query.creator, adult });
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) };
},
{ query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })) }) },
)
.get(
"/products/:id",
async ({ person, params }) => {
const adult = await personAdult(person);
const p = await productCall(() => products.get(params.id, adult));
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
return {
product: productView(p.product, p.creator, p.creatorUsername),
revisions: p.revisions.map((r) => ({
number: r.number,
rating: r.rating,
notes: r.notes,
retracted: r.retracted,
files: r.files,
size: r.files.reduce((n, f) => n + f.size, 0),
createdAt: r.createdAt.toISOString(),
catalogs: p.hosts.filter((h) => h.number === r.number).map((h) => h.name),
})),
inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId,
};
},
{ params: productParam },
)
.patch(
"/products/:id",
async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body));
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
return { product: productView(p) };
},
{
signedIn: true,
params: productParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 20000 })),
rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
}),
},
)
.post(
"/products/:id/revisions/:n/retract",
async ({ me, params }) => {
await productCall(() => products.retract(me.id, params.id, params.n));
await audited(me.id, "product.retract", params.id, { revision: params.n });
return { ok: true };
},
{ signedIn: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) },
)
// What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files.
.get(
"/products/:id/files",
async ({ person, params }) => {
const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person)));
return {
product: r.product.id,
revision: r.revision.number,
rating: r.revision.rating,
parent: r.revision.parent,
name: r.product.name,
creator: r.creator ?? "",
files: r.revision.files,
catalogs: r.catalogs,
token: r.revision.token,
};
},
{ params: productParam },
)
.post(
"/access",
async ({ me }) => ({
token: await signer.sign("t5de-access", { sub: me.id, adult: await personAdult(me) }, 60 * 60),
}),
{ signedIn: true },
)
// Libraries.
.get(
"/library",
async ({ me }) => ({
library: (await products.library(me.id)).map((l) => ({
product: productView(l.product),
stayOn: l.stayOn,
addedAt: l.addedAt.toISOString(),
})),
}),
{ signedIn: true },
)
.put(
"/library/:id",
async ({ me, params }) => {
await productCall(async () => products.add(me.id, params.id, await personAdult(me)));
realtime.notify(me.id, { type: "library" });
return { ok: true };
},
{ signedIn: true, params: productParam },
)
.delete(
"/library/:id",
async ({ me, params, set }) => {
await products.remove(me.id, params.id);
realtime.notify(me.id, { type: "library" });
set.status = 204;
},
{ signedIn: true, params: productParam },
)
.patch(
"/library/:id",
async ({ me, params, body }) => {
await productCall(() => products.stayOn(me.id, params.id, body.stayOn));
realtime.notify(me.id, { type: "library" });
return { ok: true };
},
{
signedIn: true,
params: productParam,
body: t.Object({ stayOn: t.Union([t.Integer({ minimum: 1 }), t.Null()]) }),
},
)
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
.get(
"/age",
@@ -583,6 +818,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
)
// The console.
.get(
"/console/catalogs",
async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }),
{ moderator: true },
)
.post(
"/console/catalogs/:id/:decision",
async ({ me, params }) => {
const c = await productCall(() =>
products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"),
);
await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name });
return { catalog: catalogView(c) };
},
{ moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) },
)
.get(
"/console",
async ({ me }) => {
@@ -723,10 +974,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const app = new Elysia()
.all("/api/auth/*", ({ request }) => auth.handler(request))
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
.get("/.well-known/t5de-keys.json", async () => signer.jwks())
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime, age, rooms };
return { app, realtime, age, rooms, products, signer };
}
export type App = ReturnType<typeof createApp>["app"];
+51
View File
@@ -0,0 +1,51 @@
// Product descriptions: HTML, as on IMVU, made safe by an allowlist (docs/design.md, "Product info"). Cleaned when
// saved; whatever shows them cleans again.
import sanitize from "sanitize-html";
export const maxDescription = 20_000;
const color = [/^#[0-9a-f]{3,8}$/i, /^[a-z]{3,20}$/i, /^rgba?\(\s*[\d.\s,%]+\)$/i];
const size = [/^\d{1,3}(\.\d+)?(px|pt|em|rem|%)$/, /^(xx-small|x-small|small|medium|large|x-large|xx-large)$/];
const options: sanitize.IOptions = {
allowedTags: [
"b", "i", "u", "s", "em", "strong", "small", "big", "sub", "sup", "font", "span", "div", "p", "br", "hr",
"center", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6",
"a", "img",
],
allowedAttributes: {
a: ["href", "rel", "target"], // rel and target are always set by the transform below
img: ["src", "alt", "width", "height"],
font: ["color", "size"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan"],
"*": ["style"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: { img: ["https"] },
allowProtocolRelative: false,
allowedStyles: {
"*": {
color,
"background-color": color,
"font-size": size,
"font-weight": [/^(normal|bold|bolder|lighter|[1-9]00)$/],
"font-style": [/^(normal|italic|oblique)$/],
"text-align": [/^(left|right|center|justify)$/],
"text-decoration": [/^(none|underline|line-through|overline)$/],
},
},
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
}),
},
// Images whose address was refused are dropped.
exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src,
disallowedTagsMode: "discard",
};
export function cleanHTML(html: string): string {
return sanitize(html, options).trim();
}
+1 -1
View File
@@ -22,7 +22,7 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret });
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient() });
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
+72
View File
@@ -0,0 +1,72 @@
// The hub's signing key (Ed25519) for the tokens catalogs and proxies check: upload, access and revision tokens
// (docs/products.md). Made on first use and kept in the database; the public half is at
// /.well-known/t5de-keys.json.
import { desc, schema, type Database } from "@t5de/db";
import { exportJWK, generateKeyPair, importJWK, jwtVerify, SignJWT, type JWK, type JWTPayload } from "jose";
const { signingKey } = schema;
type Key = { kid: string; publicJWK: JWK; privateKey: CryptoKey };
export class Signer {
private key?: Promise<Key>;
constructor(
private readonly db: Database,
private readonly issuer: string,
) {}
private load(): Promise<Key> {
this.key ??= (async () => {
const [row] = await this.db.select().from(signingKey).orderBy(desc(signingKey.createdAt)).limit(1);
if (row) {
return {
kid: row.id,
publicJWK: row.publicKey,
privateKey: (await importJWK(row.privateKey, "EdDSA")) as CryptoKey,
};
}
const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
const publicJWK = await exportJWK(pair.publicKey);
const kid = Buffer.from(crypto.getRandomValues(new Uint8Array(9))).toString("base64url");
await this.db.insert(signingKey).values({
id: kid,
publicKey: publicJWK as Record<string, string>,
privateKey: (await exportJWK(pair.privateKey)) as Record<string, string>,
});
return { kid, publicJWK, privateKey: pair.privateKey };
})();
return this.key;
}
/** A token of type typ with these claims; expiresIn in seconds (none: no expiry). */
async sign(typ: string, claims: Record<string, unknown>, expiresIn?: number): Promise<string> {
const k = await this.load();
let jwt = new SignJWT({ ...claims, typ })
.setProtectedHeader({ alg: "EdDSA", kid: k.kid, typ: "JWT" })
.setIssuer(this.issuer)
.setIssuedAt();
if (expiresIn) jwt = jwt.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn);
return jwt.sign(k.privateKey);
}
/** A token's claims, if the hub signed it, it's of type typ, and it hasn't expired; else undefined. */
async verify(token: string, typ: string): Promise<JWTPayload | undefined> {
const k = await this.load();
try {
const { payload } = await jwtVerify(token, await importJWK(k.publicJWK, "EdDSA"), {
issuer: this.issuer,
algorithms: ["EdDSA"],
});
return payload.typ === typ ? payload : undefined;
} catch {
return undefined;
}
}
/** The public keys, as a JWKS. */
async jwks() {
const k = await this.load();
return { keys: [{ ...k.publicJWK, kid: k.kid, alg: "EdDSA", use: "sig" }] };
}
}
+367
View File
@@ -0,0 +1,367 @@
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
import { createHash, timingSafeEqual } from "node:crypto";
import { and, desc, eq, ilike, inArray, ne, or, sql, schema, type Database, type FileEntry } from "@t5de/db";
import { cleanHTML, maxDescription } from "./html";
import type { Signer } from "./keys";
const { catalog, product, productRevision, hosting, library, user } = schema;
export type Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect;
export type Revision = typeof productRevision.$inferSelect;
/** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
const base32 = "abcdefghijklmnopqrstuvwxyz234567";
export const newProductID = () =>
[...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join("");
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const nameRule = (s: unknown, max: number) => {
const v = typeof s === "string" ? s.trim() : "";
// eslint-disable-next-line no-control-regex
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
return v;
};
/** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
const out: FileEntry[] = [];
const names = new Set<string>();
for (const f of files) {
const name = typeof f?.name === "string" ? f.name : "";
const hash = typeof f?.sha256 === "string" ? f.sha256 : "";
const size = Number(f?.size);
if (!name || name.length > 200 || names.has(name) || /[/\\]/.test(name)) return undefined;
if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(size) || size < 0) return undefined;
names.add(name);
out.push({ name, sha256: hash, size });
}
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
// Field by field: stored lists come back from JSONB with their keys in another order.
const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
export class Products {
constructor(
private readonly db: Database,
private readonly signer: Signer,
) {}
// ---- catalogs ----
async registerCatalog(
ownerId: string,
input: { name: string; url: string; contact: string; visibility: string; membership: string },
) {
const name = nameRule(input.name, 64);
const contact = nameRule(input.contact, 200);
let url: URL;
try {
url = new URL(input.url);
} catch {
throw new ProductError(400, "invalid", "That address isn't a URL.");
}
if (!name || !contact) throw new ProductError(400, "invalid", "A name and a takedown contact are needed.");
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
throw new ProductError(400, "invalid", "Catalogs need an https address.");
if (!["public", "private"].includes(input.visibility) || !["open", "approval", "invite"].includes(input.membership))
throw new ProductError(400, "invalid", "Choose a visibility and a membership.");
const clean = url.origin + url.pathname.replace(/\/+$/, "");
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, clean));
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
const key = randomKey();
const [c] = await this.db
.insert(catalog)
.values({
id: newProductID(),
ownerId,
name,
url: clean,
contact,
visibility: input.visibility,
membership: input.membership,
keyHash: sha256(key),
})
.returning();
return { catalog: c!, key };
}
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */
async catalogFor(header: string | null): Promise<Catalog | undefined> {
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
if (!m) return undefined;
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
if (!c || c.status !== "approved") return undefined;
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
}
async catalogs(viewerId?: string) {
const rows = await this.db
.select()
.from(catalog)
.where(
or(
and(eq(catalog.status, "approved"), eq(catalog.visibility, "public")),
viewerId ? eq(catalog.ownerId, viewerId) : sql`false`,
),
)
.orderBy(catalog.name);
return rows;
}
async allCatalogs() {
return this.db.select().from(catalog).orderBy(desc(catalog.createdAt));
}
async setCatalogStatus(id: string, status: "approved" | "rejected") {
const [c] = await this.db.update(catalog).set({ status }).where(eq(catalog.id, id)).returning();
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
return c;
}
// ---- uploads ----
/** An upload token for a person to send a product (or a new revision of theirs) to a catalog. */
async uploadToken(personId: string, catalogId: string, productId?: string) {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
if (productId) await this.own(personId, productId);
const token = await this.signer.sign(
"t5de-upload",
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
30 * 60,
);
return { token, url: `${c.url}/api/v1/uploads` };
}
/** A catalog registers an uploaded revision: a new product, or a new revision of one. */
async registerRevision(
c: Catalog,
body: { uploadToken: string; files: unknown; rating: string; parent: number; name: string; notes: string },
) {
const claims = await this.signer.verify(body.uploadToken, "t5de-upload");
if (!claims || claims.catalog !== c.id || typeof claims.sub !== "string")
throw new ProductError(401, "bad_token", "The upload token isn't valid for this catalog.");
const files = checkFiles(body.files);
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
if (body.rating !== "general" && body.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub;
return this.db.transaction(async (tx) => {
let p: Product;
if (typeof claims.product === "string") {
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
if (!found || found.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (!notes) throw new ProductError(400, "invalid", "Say what changed in this revision.");
const [latest] = await tx
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, found.id), eq(productRevision.number, found.latest)));
if (latest && sameFiles(latest.files, files))
throw new ProductError(409, "unchanged", "These files are the same as the latest revision's.");
p = found;
} else {
const name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx
.insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
.returning();
p = made!;
}
const number = p.latest + 1;
const token = await this.signer.sign("t5de-revision", {
product: p.id,
revision: number,
rating: body.rating,
files,
parent,
});
await tx.insert(productRevision).values({
productId: p.id,
number,
files,
rating: body.rating,
parent,
notes,
token,
uploadedBy: personId,
});
// A product is adult if any revision is (or its creator says so).
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number };
});
}
// ---- products ----
private async own(personId: string, productId: string) {
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
if (!p) throw new ProductError(404, "not_found", "No such product.");
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
return p;
}
/** Products, newest first; adult ones only if adult. */
async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0)];
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()));
return this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.where(and(...conds))
.orderBy(desc(product.createdAt))
.limit(Math.min(opts.limit ?? 60, 200));
}
async get(id: string, adult: boolean) {
const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
.from(productRevision)
.where(eq(productRevision.productId, id))
.orderBy(desc(productRevision.number));
const hosts = await this.db
.select({ number: hosting.number, name: catalog.name, url: catalog.url })
.from(hosting)
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
.where(and(eq(hosting.productId, id), eq(catalog.status, "approved")));
return { ...row, revisions, hosts };
}
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
const p = await this.own(personId, id);
const set: Partial<Product> = {};
if (input.name !== undefined) {
const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
}
if (input.description !== undefined) {
if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long.");
set.description = cleanHTML(input.description);
}
if (input.rating !== undefined) {
if (input.rating === "adult") set.rating = "adult";
else if (input.rating === "general" && p.rating === "adult") {
// Lowering is reviewed first (design.md); refused for now unless every revision is general.
const [adultRev] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult")))
.limit(1);
if (adultRev) throw new ProductError(400, "invalid", "A product with adult revisions stays adult.");
set.rating = "general";
}
}
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
return updated!;
}
async retract(personId: string, id: string, number: number) {
await this.own(personId, id);
const [r] = await this.db
.update(productRevision)
.set({ retracted: true })
.where(and(eq(productRevision.productId, id), eq(productRevision.number, number)))
.returning();
if (!r) throw new ProductError(404, "not_found", "No such revision.");
return r;
}
// ---- libraries ----
async library(personId: string) {
return this.db
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt })
.from(library)
.innerJoin(product, eq(product.id, library.productId))
.where(eq(library.userId, personId))
.orderBy(desc(library.addedAt));
}
async inLibrary(personId: string, productId: string) {
const [row] = await this.db
.select({ stayOn: library.stayOn })
.from(library)
.where(and(eq(library.userId, personId), eq(library.productId, productId)));
return row;
}
async add(personId: string, productId: string, adult: boolean) {
await this.get(productId, adult);
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
}
async remove(personId: string, productId: string) {
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
}
async stayOn(personId: string, productId: string, number: number | null) {
if (number !== null) {
const [r] = await this.db
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
if (!r || r.retracted) throw new ProductError(404, "not_found", "No such revision.");
}
const [row] = await this.db
.update(library)
.set({ stayOn: number })
.where(and(eq(library.userId, personId), eq(library.productId, productId)))
.returning();
if (!row) throw new ProductError(404, "not_found", "That product isn't in your library.");
}
/**
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
if (usable.length === 0) throw new ProductError(404, "not_found", "That product has no usable revision.");
const r =
usable.find((r) => r.number === want) ??
usable.find((r) => r.number < want) ??
usable[usable.length - 1]!;
const hosts = p.hosts.filter((h) => h.number === r.number).map((h) => h.url);
return { product: p.product, creator: p.creator, revision: r, catalogs: hosts };
}
/** People's display names for creators, by ID (for the console). */
async creators(ids: string[]) {
if (!ids.length) return [];
return this.db.select({ id: user.id, username: user.displayUsername }).from(user).where(inArray(user.id, ids));
}
}
+5
View File
@@ -137,6 +137,11 @@ export class Realtime {
if (changed) this.changed(chats);
}
/** Sends a message to all of a person's connections. */
notify(userId: string, message: object) {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
+170
View File
@@ -0,0 +1,170 @@
// The product registry: catalogs, uploads registered by catalogs, product info, revisions, libraries, tokens.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, 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));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string, imvu?: { id: number; ageVerified: boolean }): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") };
if (imvu) {
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false });
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
}
return p;
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
test("catalogs register uploads; products have revisions, info and libraries", async () => {
const admin = await person("admin1");
const maker = await person("maker", { id: 501, ageVerified: true });
const kid = await person("kid", { id: 502, ageVerified: false });
// A catalog registers; it can't do anything until approved.
const reg = await call(maker, "POST", "/api/v1/catalogs", {
name: "Maker's Shelf",
url: "http://localhost:4102/",
contact: "[email protected]",
visibility: "public",
membership: "open",
});
expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
expect((await call(admin, "POST", `/api/v1/console/catalogs/${catalogID}/approve`)).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs.map((c: any) => c.name)).toEqual(["Maker's Shelf"]);
// The maker gets an upload token; the catalog registers what was uploaded with it.
const up = await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID });
expect(up.json.url).toBe("http://localhost:4102/api/v1/uploads");
const files = [file("tex.png", 2), file("index.xml", 1)];
const revision = (token: string, extra: object = {}) =>
call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files, rating: "general", parent: 500, name: "Blue Coat", notes: "", ...extra }, auth);
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
expect(page.revisions[0].files.map((f: any) => f.name)).toEqual(["index.xml", "tex.png"]);
expect(page.revisions[0].catalogs).toEqual(["Maker's Shelf"]);
// Descriptions are cleaned by the allowlist.
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"></p>`,
});
expect(edited.status).toBe(200);
expect(edited.json.product.description).toBe(
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a></p>`,
);
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
// A new revision needs notes and different files.
const up2 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).json.token;
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(1);
// Retracting revision 1 moves those staying on it to the nearest one that isn't.
await call(maker, "POST", `/api/v1/products/${id}/revisions/1/retract`);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(2);
// The revision token is the hub's signature on the files, checkable with its public keys.
const keys = createLocalJWKSet((await call(undefined, "GET", "/.well-known/t5de-keys.json")).json);
const { payload } = await jwtVerify(files2.token, keys, { issuer: apiURL });
expect(payload).toMatchObject({ typ: "t5de-revision", product: id, revision: 2, rating: "general", parent: 500 });
// Access tokens say whether the person is adult.
const access = async (p: Person) => (await jwtVerify((await call(p, "POST", "/api/v1/access")).json.token, keys)).payload;
expect(await access(maker)).toMatchObject({ typ: "t5de-access", adult: true });
expect(await access(kid)).toMatchObject({ typ: "t5de-access", adult: false });
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
});