Files
t5de-hub/apps/api/src/products.ts
T
dhkatz 58a3e70a63
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m13s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 12s
feat: file blocks (#10)
Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:48:07 +00:00

896 lines
39 KiB
TypeScript

// 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<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);
// 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 <id>:<key>` header names, if it's approved (or suspended, when asked)
* and the key is right. */
async catalogFor(header: string | null, suspendedToo = false): 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" || (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<ParentInfo | undefined> {
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<number> {
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<Pick<Product, InfoField>> = {};
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<string, { from: unknown; to: unknown }> = {};
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<CategoryPath[]> {
const parent = (await this.latestParent(p))?.parentInfo?.categories ?? [];
const seen = await this.db
.selectDistinct({ path: sql<CategoryPath>`${productRevision.parentInfo}->'categories'` })
.from(productRevision)
.where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`);
const byId = new Map<number, CategoryPath>();
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<string, unknown> = {};
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<number> {
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<number>`(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<number>`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<boolean> {
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));
}
}