// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host, // suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do. import { createHash } from "node:crypto"; import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db"; import { ProductError } from "./products"; const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } = schema; export type Catalog = typeof catalog.$inferSelect; export type MemberStatus = "requested" | "member" | "banned"; const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url"); // Invite codes: 10 letters and digits without look-alikes. const inviteCode = () => { const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join(""); }; const plain = (s: unknown, max: number) => { const v = typeof s === "string" ? s.trim() : ""; // eslint-disable-next-line no-control-regex if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined; return v; }; /** A catalog's address, checked and without a trailing slash (https, or localhost for development). */ export function catalogURL(input: string): string { let url: URL; try { url = new URL(input); } catch { throw new ProductError(400, "invalid", "That address isn't a URL."); } if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") throw new ProductError(400, "invalid", "Catalogs need an https address."); return url.origin + url.pathname.replace(/\/+$/, ""); } export type CatalogSettings = { name?: string; url?: string; contact?: string; visibility?: "public" | "private"; membership?: "open" | "approval" | "invite"; maxRating?: "general" | "adult"; uploads?: "anyone" | "members" | "team"; mirrors?: "auto" | "approve" | "off"; }; export class Catalogs { constructor( private readonly db: Database, /** Whether a catalog answers at an address (its /health); checked when its address changes. */ private readonly healthy: (url: string) => Promise = defaultHealthy, ) {} async get(id: string): Promise { const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id)); if (!c) throw new ProductError(404, "not_found", "No such catalog."); return c; } /** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */ async update(id: string, input: CatalogSettings) { const c = await this.get(id); const set: Partial = {}; if (input.name !== undefined) { const name = plain(input.name, 64); if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters."); set.name = name; } if (input.contact !== undefined) { const contact = plain(input.contact, 200); if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed."); set.contact = contact; } if (input.url !== undefined) { const url = catalogURL(input.url); if (url !== c.url) { const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url)); if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered."); if (!(await this.healthy(url))) throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?"); set.url = url; } } for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const) if (input[k] !== undefined) (set as Record)[k] = input[k]; const changed: Record = {}; for (const [k, v] of Object.entries(set)) { const before = (c as Record)[k]; if (before !== v) changed[k] = { from: before, to: v }; } if (Object.keys(changed).length === 0) return { catalog: c, changed }; const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning(); return { catalog: updated!, changed }; } /** A new key for the catalog; the old one stops working at once. */ async rotateKey(id: string): Promise { await this.get(id); const key = randomKey(); await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id)); return key; } /** Suspends or reinstates an approved catalog; tells it through its changes feed. */ async setSuspended(id: string, suspended: boolean, reason: string) { const c = await this.get(id); if (suspended && c.status !== "approved") throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended."); if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended."); const status = suspended ? "suspended" : "approved"; const [updated] = await this.db .update(catalog) .set({ status, statusReason: suspended ? reason : "" }) .where(eq(catalog.id, id)) .returning(); await this.change(id, { type: "status", status }); return updated!; } // ---- teams ---- async addRole(catalogId: string, personId: string, role: string, by: string) { const c = await this.get(catalogId); if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner."); await this.db .insert(roleAssignment) .values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by }) .onConflictDoNothing(); } async removeRole(catalogId: string, personId: string, role: string) { const gone = await this.db .delete(roleAssignment) .where( and( eq(roleAssignment.userId, personId), eq(roleAssignment.role, role), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId), ), ) .returning(); if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here."); } // ---- members ---- async membership(catalogId: string, personId: string): Promise { const [m] = await this.db .select({ status: catalogMember.status }) .from(catalogMember) .where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId))); return m?.status as MemberStatus | undefined; } /** * A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which * also lets them into approval catalogs at once). Returns their status. */ async join(catalogId: string, personId: string, invite?: string): Promise { const c = await this.get(catalogId); if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog."); const now = await this.membership(catalogId, personId); if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it."); if (now === "member") return "member"; let status: MemberStatus; let usedInvite: string | null = null; if (invite) { const updated = await this.db .update(catalogInvite) .set({ uses: sql`${catalogInvite.uses} + 1` }) .where( and( eq(catalogInvite.code, invite), eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt), or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())), or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`), ), ) .returning(); if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked."); status = "member"; usedInvite = invite; } else if (c.membership === "open") status = "member"; else if (c.membership === "approval") status = "requested"; else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only."); await this.db .insert(catalogMember) .values({ catalogId, userId: personId, status, invite: usedInvite }) .onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } }); return status; } /** A person leaves (or withdraws their request). A ban stays. */ async leave(catalogId: string, personId: string) { await this.db .delete(catalogMember) .where( and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`), ); } async members(catalogId: string, status?: MemberStatus) { const conds = [eq(catalogMember.catalogId, catalogId)]; if (status) conds.push(eq(catalogMember.status, status)); const rows = await this.db .select({ personId: catalogMember.userId, username: user.username, name: user.displayUsername, status: catalogMember.status, invite: catalogMember.invite, since: catalogMember.updatedAt, }) .from(catalogMember) .innerJoin(user, eq(user.id, catalogMember.userId)) .where(and(...conds)) .orderBy(desc(catalogMember.updatedAt)) .limit(500); return rows.map((r) => ({ personId: r.personId, username: r.name ?? r.username ?? "", status: r.status as MemberStatus, invited: r.invite !== null, since: r.since.toISOString(), })); } async memberCounts(catalogId: string) { const rows = await this.db .select({ status: catalogMember.status, n: count() }) .from(catalogMember) .where(eq(catalogMember.catalogId, catalogId)) .groupBy(catalogMember.status); const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0; return { members: get("member"), requests: get("requested"), banned: get("banned") }; } /** The team sets a person's membership: let them in, or ban them. */ async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) { await this.get(catalogId); await this.db .insert(catalogMember) .values({ catalogId, userId: personId, status, decidedBy: by }) .onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } }); } async removeMember(catalogId: string, personId: string) { const gone = await this.db .delete(catalogMember) .where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId))) .returning(); if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be."); } // ---- invites ---- async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) { await this.get(catalogId); const [inv] = await this.db .insert(catalogInvite) .values({ code: inviteCode(), catalogId, createdBy: by, maxUses: opts.maxUses ?? null, expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null, }) .returning(); return inv!; } async invites(catalogId: string) { return this.db .select() .from(catalogInvite) .where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt))) .orderBy(desc(catalogInvite.createdAt)); } async revokeInvite(catalogId: string, code: string) { const r = await this.db .update(catalogInvite) .set({ revokedAt: new Date() }) .where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt))) .returning(); if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation."); } // ---- what it hosts ---- /** The products a catalog hosts, newest first, with the revisions it hosts of each. */ async hosted(catalogId: string) { const rows = await this.db .select({ id: product.id, name: product.name, rating: product.rating, creator: user.displayUsername, creatorUsername: user.username, removed: product.removedAt, deleted: product.deletedAt, revisions: sql`array_agg(${hosting.number} order by ${hosting.number})`, since: sql`min(${hosting.createdAt})`, }) .from(hosting) .innerJoin(product, eq(product.id, hosting.productId)) .innerJoin(user, eq(user.id, product.creatorId)) .where(eq(hosting.catalogId, catalogId)) .groupBy(product.id, user.id) .orderBy(desc(sql`min(${hosting.createdAt})`)) .limit(500); return rows.map((r) => ({ id: r.id, name: r.name, rating: r.rating, creator: r.creator ?? r.creatorUsername ?? "", removed: r.removed !== null, deleted: r.deleted !== null, revisions: r.revisions, since: new Date(r.since).toISOString(), })); } async hostedCounts(catalogId: string) { const [r] = await this.db .select({ products: sql`count(distinct ${hosting.productId})::int`, revisions: sql`count(*)::int`, bytes: sql`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`, }) .from(hosting) .innerJoin( productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)), ) .where(eq(hosting.catalogId, catalogId)); return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) }; } /** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */ async delist(catalogId: string, productId: string) { const gone = await this.db .delete(hosting) .where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId))) .returning(); if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product."); const revs = await this.db .select({ number: productRevision.number, files: productRevision.files }) .from(productRevision) .where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number)))); for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files }); return gone.map((g) => g.number); } // ---- the changes feed ---- async change(catalogId: string, data: Record) { await this.db.insert(catalogChange).values({ catalogId, data }); } /** Changes after a cursor, oldest first (at most 500 at a time). */ async changes(catalogId: string, after: number) { const rows = await this.db .select() .from(catalogChange) .where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after))) .orderBy(asc(catalogChange.id)) .limit(500); return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after }; } } async function defaultHealthy(url: string): Promise { try { const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) }); return res.ok; } catch { return false; } }