// 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, gt, ilike, inArray, isNull, ne, or, sql, schema, type Database, type FileEntry, type ParentInfo, } from "@t5de/db"; import { cleanHTML, maxDescription } from "./html"; import type { ImvuClient } from "./imvu"; import type { Signer } from "./keys"; const { catalog, product, productRevision, hosting, library, user } = schema; 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(); 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); // A parent's info another revision looked up lately is reused rather than asking IMVU again. const parentInfoFresh = 7 * 24 * 60 * 60 * 1000; /** Each product's latest revision (joined on product.latest, for lists). */ const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest)); export class Products { constructor( private readonly db: Database, private readonly signer: Signer, private readonly imvu?: ImvuClient, ) {} // ---- catalogs ---- 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 :` header names, if it's approved and the key is right. */ async catalogFor(header: string | null): Promise { 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; const made = await this.db.transaction(async (tx) => { let p: Product; if (typeof claims.product === "string") { const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update"); 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 }; }); // The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries). if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {}); return made; } // ---- parents (the IMVU products revisions derive from) ---- /** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */ async lookUpParent(productId: string, number: number, parent: number): Promise { if (!this.imvu || parent <= 0) return undefined; const since = new Date(Date.now() - parentInfoFresh).toISOString(); const [known] = await this.db .select({ info: productRevision.parentInfo }) .from(productRevision) .where( and( eq(productRevision.parent, parent), sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`, ), ) .limit(1); let info = known?.info ?? undefined; if (!info) { const p = await this.imvu.product(parent); info = { found: p !== null, image: p?.image ?? null, categories: p?.categories ?? [], gender: p?.gender ?? "", checkedAt: new Date().toISOString(), }; } await this.db .update(productRevision) .set({ parentInfo: info }) .where(and(eq(productRevision.productId, productId), eq(productRevision.number, number))); return info; } /** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */ async fillParentInfo(limit = 20): Promise { const due = await this.db .select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent }) .from(productRevision) .where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo))) .orderBy(desc(productRevision.createdAt)) .limit(limit); let n = 0; for (const r of due) { try { if (await this.lookUpParent(r.productId, r.number, r.parent)) n++; } catch { // IMVU unavailable: try again next time. } } return n; } // ---- products ---- 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."); if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation."); if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product."); return p; } /** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */ async delete(personId: string, id: string) { await this.own(personId, id); await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id)); } /** Moderation removes a product: gone everywhere. */ async removeByModeration(id: string, reason: string) { const [p] = await this.db .update(product) .set({ removedAt: new Date(), removedReason: reason }) .where(and(eq(product.id, id), isNull(product.removedAt))) .returning(); if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already."); return p; } /** * Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in * that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info. */ async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) { const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)]; if (!opts.adult) conds.push(eq(product.rating, "general")); if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase())); if (opts.category !== undefined) conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`); const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200)); return this.db .select({ product, creator: user.displayUsername, creatorUsername: user.username, parent: productRevision.parent, parentInfo: productRevision.parentInfo, }) .from(product) .innerJoin(user, eq(user.id, product.creatorId)) .leftJoin(productRevision, latestJoin) .where(and(...conds)) .orderBy(desc(product.createdAt)) .limit(limit); } /** A product's latest revision's parent and parent info (for views of one product). */ async latestParent(p: Product) { const [r] = await this.db .select({ parent: productRevision.parent, parentInfo: productRevision.parentInfo }) .from(productRevision) .where(and(eq(productRevision.productId, p.id), eq(productRevision.number, p.latest))); return r; } async get(id: string, adult: boolean) { 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.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation."); if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product."); const revisions = await this.db .select() .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 = {}; 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 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."); } 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, creator: user.displayUsername, parent: productRevision.parent, parentInfo: productRevision.parentInfo, }) .from(library) .innerJoin(product, eq(product.id, library.productId)) .innerJoin(user, eq(user.id, product.creatorId)) .leftJoin(productRevision, latestJoin) .where(and(eq(library.userId, personId), isNull(product.removedAt))) .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) { const p = await this.get(productId, adult); if (p.product.deletedAt && !(await this.inLibrary(personId, productId))) throw new ProductError(410, "deleted", "Its creator deleted this product."); await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing(); } 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)); } }