// 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, 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( readonly status: number, readonly slug: string, message: string, ) { super(message); } } /** 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(""); 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; }; /** 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; 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; /** * Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a * catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team. */ export const availableTo = (personId: string) => sql`(${product.status} = 'published' and ( ${product.creatorId} = ${personId} or exists ( select 1 from ${hosting} h join ${catalog} c on c.id = h.catalog_id left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId} where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned' and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId} or exists (select 1 from ${roleAssignment} r where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id)) )))`; /** Each product's latest revision (joined on product.latest, for lists). */ const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest)); export class Products { constructor( private readonly db: Database, private readonly signer: Signer, private readonly imvu?: ImvuClient, ) {} // ---- catalogs ---- 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 (or suspended, when asked) * and the key is right. */ async catalogFor(header: string | null, suspendedToo = false): 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" || (suspendedToo && c.status === "suspended"))) 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); 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 } : {}) }, 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."); 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; 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, status: "draft" }) .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, draft: p.status === "draft" }; }); // The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries). if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {}); return made; } // ---- parents (the IMVU products revisions derive from) ---- /** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */ async lookUpParent(productId: string, number: number, parent: number): Promise { 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; } /** Undoes a moderation removal (an upheld appeal, a mistake): its page, files and library entries are back, since * removing only hides them. */ async unremove(id: string) { const [p] = await this.db .update(product) .set({ removedAt: null, removedReason: null }) .where(and(eq(product.id, id), sql`${product.removedAt} is not null`)) .returning(); if (!p) throw new ProductError(404, "not_found", "No such removed product."); return p; } /** * Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`: * only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with * the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info. */ async list(opts: { q?: string; creator?: string; category?: number; tag?: string; catalog?: string; adult: boolean; viewerId?: string; limit?: number; }) { const conds = [ ne(product.latest, 0), eq(product.status, "published"), isNull(product.deletedAt), isNull(product.removedAt), opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true), ]; if (!opts.adult) conds.push(eq(product.rating, "general")); if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase())); if (opts.category !== undefined) conds.push( sql`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`, ); const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined; if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`); if (opts.catalog) conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`); const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200)); return this.db .select({ product, creator: user.displayUsername, creatorUsername: user.username, 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, viewer?: ProductViewer) { const [row] = await this.db .select({ product, creator: user.displayUsername, creatorUsername: user.username }) .from(product) .innerJoin(user, eq(user.id, product.creatorId)) .where(eq(product.id, id)); if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product."); if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation."); // Its creator and moderators always see it: drafts, and adult products without an adult account. const responsible = viewer?.moderator || viewer?.id === row.product.creatorId; if (row.product.status === "draft" && !responsible) throw new ProductError(404, "not_found", "No such product."); if (row.product.rating === "adult" && !adult && !responsible) throw new ProductError(404, "not_found", "No such product."); const revisions = await this.db .select() .from(productRevision) .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 }; } // ---- 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> = {}; if (input.name !== undefined) { const name = nameRule(input.name, 64); if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters."); next.name = name; } if (input.description !== undefined) { if (input.description.length > maxDescription) throw new ProductError(400, "invalid", "That description is too long."); next.description = cleanHTML(input.description); } if (input.rating !== undefined) { if (input.rating !== "general" && input.rating !== "adult") throw new ProductError(400, "invalid", "The rating must be general or adult."); next.rating = input.rating; } 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 = {}; 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 { const parent = (await this.latestParent(p))?.parentInfo?.categories ?? []; const seen = await this.db .selectDistinct({ path: sql`${productRevision.parentInfo}->'categories'` }) .from(productRevision) .where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`); const byId = new Map(); 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 = {}; 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 { 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`(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`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) { 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), availableTo(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, 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 { 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))); } 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, 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); 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)); } }