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]>
896 lines
39 KiB
TypeScript
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));
|
|
}
|
|
}
|