Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s
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:
1 parent
7fea63fc4f
commit
b4f6fce9df
15 files changed
+2883
-14
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user