diff --git a/CLAUDE.md b/CLAUDE.md index b91a81b..01da70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,4 +26,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che (`t5de-proxy...`). - On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no `bunx`: use `bun x`). -- Calls to IMVU (`apps/api/src/imvu.ts`, its public user API) are rare and rate-limited: linking and age re-checks. +- Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms, + and the IMVU products T5 products derive from (`parent_info`, looked up once per revision). diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ad0a955..a10abcd 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,7 +2,7 @@ // origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer // token. import { Elysia, t } from "elysia"; -import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db"; +import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db"; import type { Auth } from "./auth"; import { AgeError, AgeService } from "./age"; import { imvuAgeProvider, imvuResult } from "./age/imvu"; @@ -76,7 +76,7 @@ class Limiter { export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); const signer = new Signer(db, apiURL ?? "http://localhost:4101"); - const products = new Products(db, signer); + const products = new Products(db, signer, imvu); const rooms = roomsService ?? new Rooms(db, imvu); const realtime = rt ?? @@ -178,10 +178,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms } }; + /** `latest`: the latest revision's parent and its info (the image and categories are the parent's). */ const productView = ( p: typeof schema.product.$inferSelect, creator?: string | null, creatorUsername?: string | null, + latest?: { parent: number | null; parentInfo: ParentInfo | null } | null, ) => ({ id: p.id, name: p.name, @@ -190,6 +192,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms latest: p.latest, creator: creator ?? creatorUsername ?? "", creatorUsername: creatorUsername ?? "", + parent: latest?.parent ?? 0, + image: latest?.parentInfo?.image ?? null, + categories: latest?.parentInfo?.categories ?? [], createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }); @@ -616,10 +621,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms "/products", async ({ person, query }) => { const adult = await personAdult(person); - const rows = await products.list({ q: query.q, creator: query.creator, adult }); - return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) }; + const rows = await products.list({ ...query, adult }); + return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) }; + }, + { + query: t.Object({ + q: t.Optional(t.String({ maxLength: 100 })), + creator: t.Optional(t.String({ maxLength: 64 })), + category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })), + limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })), + }), }, - { query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })) }) }, ) .get( "/products/:id", @@ -627,8 +639,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const adult = await personAdult(person); const p = await productCall(() => products.get(params.id, adult)); const lib = person ? await products.inLibrary(person.id, params.id) : undefined; + const latest = p.revisions.find((r) => r.number === p.product.latest); return { - product: productView(p.product, p.creator, p.creatorUsername), + product: productView(p.product, p.creator, p.creatorUsername, latest), revisions: p.revisions.map((r) => ({ number: r.number, rating: r.rating, @@ -651,7 +664,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms async ({ me, params, body }) => { const p = await productCall(() => products.update(me.id, params.id, body)); await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) }); - return { product: productView(p) }; + return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; }, { signedIn: true, @@ -704,7 +717,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms "/library", async ({ me }) => ({ library: (await products.library(me.id)).map((l) => ({ - product: productView(l.product, l.creator), + product: productView(l.product, l.creator, undefined, l), stayOn: l.stayOn, addedAt: l.addedAt.toISOString(), })), diff --git a/apps/api/src/imvu.ts b/apps/api/src/imvu.ts index 72be4c7..d03f622 100644 --- a/apps/api/src/imvu.ts +++ b/apps/api/src/imvu.ts @@ -1,6 +1,6 @@ // IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass; -// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends -// on it for nothing else. +// rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely +// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else. export type ImvuUser = { id: number; @@ -25,6 +25,13 @@ export type ImvuRoom = { expired: boolean; }; +/** An IMVU product's public info, as T5 products derived from it show it. */ +export type ImvuProduct = { + image: string | null; // https, on *.imvu.com + categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops + gender: string; // "female", "male", or "" (either) +}; + export class NoSuchImvuUser extends Error { constructor() { super("no such IMVU account"); @@ -65,6 +72,20 @@ export class ImvuClient { } } + /** + * An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as + * Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached. + */ + async product(id: number): Promise { + if (!Number.isSafeInteger(id) || id <= 0) return null; + try { + return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id); + } catch (e) { + if (e instanceof NoSuchImvuUser) return null; + throw e; + } + } + private async wait() { const now = Date.now(); const at = Math.max(now, this.next); @@ -72,7 +93,8 @@ export class ImvuClient { if (at > now) await Bun.sleep(at - now); } - private async get(path: string): Promise { + /** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */ + private async get(path: string, missing: number[] = []): Promise { await this.wait(); let response: Response; try { @@ -83,7 +105,7 @@ export class ImvuClient { } catch (e) { throw new ImvuUnavailable(String(e)); } - if (response.status === 404) throw new NoSuchImvuUser(); + if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser(); if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`); return response.json(); } @@ -115,6 +137,36 @@ export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undef }; } +/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */ +export function imvuImage(v: unknown): string | null { + if (typeof v !== "string" || v.length > 1000) return null; + let url: URL; + try { + url = new URL(v.startsWith("//") ? "https:" + v : v); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null; + return url.href; +} + +export function parseProduct(answer: unknown, id: number): ImvuProduct | null { + const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; + const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`)); + const d = entry?.[1].data; + if (!d) return null; + const path = Array.isArray(d.category_path) ? d.category_path : []; + const categories = path.slice(0, 10).flatMap((c: unknown) => { + const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown }; + const n = Number(id); + if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return []; + return [{ id: n, name: name.trim().slice(0, 100) }]; + }); + const gender = d.gender === "female" || d.gender === "male" ? d.gender : ""; + return { image: imvuImage(d.product_image), categories, gender }; +} + // IMVU's "denormalized" answers: the user is the one /user/user- entry. export function parse(answer: unknown): ImvuUser { const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 73ddf08..c98ed76 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,12 +22,16 @@ const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const auth = createAuth(db, { apiURL, webURL, secret }); -const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); +const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); -// Age answers that can change (IMVU's) are checked again when they expire, a few at a time. +// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their +// parent's info (IMVU was unavailable, or they predate it) get it, starting now. +const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e)); +parentInfo(); setInterval(() => { age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e)); + parentInfo(); }, 10 * 60 * 1000); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index 2a47844..c2eb3bd 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -1,7 +1,23 @@ // 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 { + 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, product, productRevision, hosting, library, user } = schema; @@ -55,10 +71,17 @@ export function checkFiles(files: unknown): FileEntry[] | undefined { 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; + +/** 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 ---- @@ -164,7 +187,7 @@ export class Products { const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000); const personId = claims.sub; - return this.db.transaction(async (tx) => { + 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"); @@ -209,6 +232,62 @@ export class Products { await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number }); return { product: p.id, revision: number }; }); + // The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries). + if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {}); + return made; + } + + // ---- parents (the IMVU products revisions derive from) ---- + + /** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */ + async lookUpParent(productId: string, number: number, parent: number): Promise { + if (!this.imvu || parent <= 0) return undefined; + const since = new Date(Date.now() - parentInfoFresh).toISOString(); + const [known] = await this.db + .select({ info: productRevision.parentInfo }) + .from(productRevision) + .where( + and( + eq(productRevision.parent, parent), + sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`, + ), + ) + .limit(1); + let info = known?.info ?? undefined; + if (!info) { + const p = await this.imvu.product(parent); + info = { + found: p !== null, + image: p?.image ?? null, + categories: p?.categories ?? [], + gender: p?.gender ?? "", + checkedAt: new Date().toISOString(), + }; + } + await this.db + .update(productRevision) + .set({ parentInfo: info }) + .where(and(eq(productRevision.productId, productId), eq(productRevision.number, number))); + return info; + } + + /** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */ + async fillParentInfo(limit = 20): Promise { + const due = await this.db + .select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent }) + .from(productRevision) + .where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo))) + .orderBy(desc(productRevision.createdAt)) + .limit(limit); + let n = 0; + for (const r of due) { + try { + if (await this.lookUpParent(r.productId, r.number, r.parent)) n++; + } catch { + // IMVU unavailable: try again next time. + } + } + return n; } // ---- products ---- @@ -220,19 +299,41 @@ export class Products { return p; } - /** Products, newest first; adult ones only if adult. */ - async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) { + /** + * Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in + * that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info. + */ + async list(opts: { q?: string; creator?: string; category?: number; 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())); + if (opts.category !== undefined) + conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`); + 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 }) + .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(Math.min(opts.limit ?? 60, 200)); + .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) { @@ -294,10 +395,18 @@ export class Products { async library(personId: string) { return this.db - .select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername }) + .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(eq(library.userId, personId)) .orderBy(desc(library.addedAt)); } diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts index 77dc521..6c73663 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -4,13 +4,23 @@ import { createLocalJWKSet, jwtVerify } from "jose"; import { openDatabase, type Database } from "@t5de/db"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; -import { ImvuClient, type ImvuUser } from "../src/imvu"; +import { ImvuClient, imvuImage, parseProduct, type ImvuUser } from "../src/imvu"; const webURL = "http://web.test"; const apiURL = "http://api.test"; const accounts = new Map(); +// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it). +const imvuProducts = new Map | "down">(); const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { const url = new URL(String(input)); + const pm = url.pathname.match(/^\/product\/product-(\d+)$/); + if (pm) { + const pid = Number(pm[1]); + const data = imvuProducts.get(pid); + if (data === "down") throw new Error("connection refused"); + if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 }); + return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } }); + } const u = [...accounts.values()].find( (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), ); @@ -25,11 +35,12 @@ let db: Database; let close: () => Promise; let base: string; let stop: () => void; +let hub: ReturnType; beforeAll(async () => { ({ db, close } = await openDatabase("memory")); const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); - const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL }); + hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL }); hub.app.listen({ port: 0, hostname: "127.0.0.1" }); const server = hub.app.server as unknown as { port: number; stop: () => void }; stop = () => server.stop(); @@ -74,6 +85,9 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean }) return p; } +// Set by the first test for the next: the admin and an approved catalog. +let shared: { admin: Person; catalogID: string; auth: Record }; + const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); test("catalogs register uploads; products have revisions, info and libraries", async () => { @@ -92,6 +106,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a expect(reg.status).toBe(201); const catalogID = reg.json.catalog.id as string; const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` }; + shared = { admin, catalogID, auth }; expect(reg.json.catalog.url).toBe("http://localhost:4102"); expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0); expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404); @@ -171,3 +186,87 @@ test("catalogs register uploads; products have revisions, info and libraries", a expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204); expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]); }); + +test("IMVU product info: parsing and image addresses", () => { + expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png"); + expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png"); + for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://user@x.imvu.com/a.png", "javascript:alert(1)", 5, null]) + expect(imvuImage(bad)).toBeNull(); + const answer = { + denormalized: { + "https://api.imvu.com/product/product-9": { + data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] }, + }, + }, + }; + expect(parseProduct(answer, 9)).toEqual({ + image: "https://x.imvu.com/i.png", + categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }], + gender: "female", + }); + expect(parseProduct(answer, 10)).toBeNull(); +}); + +test("derived products carry their IMVU parent's image and categories", async () => { + const { catalogID, auth } = shared; + const dresser = await person("dresser", { id: 601, ageVerified: false }); + const coat = { + product_id: 7001, + product_name: "Coat", + rating: "GA", + product_image: "https://userimages-akm.imvu.com/productdata/coat.png", + gender: "female", + category_path: [ + { id: 106, name: "Clothing" }, + { id: 40, name: "Clothing for Female" }, + { id: 128, name: "Tops" }, + { id: 716, name: "Coats & Jackets" }, + ], + }; + imvuProducts.set(7001, coat); + imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] }); + imvuProducts.set(7003, "down"); + const upload = async (name: string, parent: number) => { + const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token; + const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth); + expect(r.status).toBe(201); + return r.json.product as string; + }; + const coatID = await upload("Dresser Coat", 7001); + const hatID = await upload("Dresser Hat", 7002); + const downID = await upload("Dresser Down", 7003); + const plainID = await upload("Dresser Plain", 0); + const missingID = await upload("Dresser Missing", 7999); + + const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product; + // Looked up in the background after registering. + for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20); + expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path }); + for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20); + expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] }); + expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] }); + expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] }); + + // Lists carry the same fields; category filters by any ID in the path. + const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products; + const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort(); + expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path }); + expect(await names("&category=128")).toEqual(["Dresser Coat"]); + expect(await names("&category=106")).toEqual(["Dresser Coat"]); + expect(await names("&category=75")).toEqual(["Dresser Hat"]); + expect(await names("&category=1")).toEqual([]); + expect(await names("&limit=2")).toHaveLength(2); + expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400); + expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400); + + // IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty. + imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] }); + await hub.products.fillParentInfo(50); + expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] }); + expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] }); + expect(await hub.products.fillParentInfo(50)).toBe(0); + + // The library shows them too. + expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200); + expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image }); +}); diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index 06d62c8..d66200a 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ActionForm, SubmitButton } from "@/components/forms"; -import { formatSize, RatingTag } from "@/components/products"; +import { formatSize, ProductImage, RatingTag } from "@/components/products"; import { PageHeader, Tag } from "@/components/status"; import { DateTime } from "@/components/time"; import { buttonVariants } from "@/components/ui/button"; @@ -30,6 +30,19 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st + {(product.image || product.categories.length > 0) && ( +
+ {product.image && ( +
+ +
+ )} + {product.categories.length > 0 && ( +

{product.categories.map((c) => c.name).join(" › ")}

+ )} +
+ )} +
{me ? ( diff --git a/apps/web/components/products.tsx b/apps/web/components/products.tsx index b765ee3..c55236f 100644 --- a/apps/web/components/products.tsx +++ b/apps/web/components/products.tsx @@ -21,15 +21,31 @@ export function formatSize(bytes: number): string { return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`; } +/** The image of the IMVU product a product derives from (IMVU's, from IMVU), if it has one. */ +export function ProductImage({ src, className }: { src: string | null | undefined; className?: string }) { + if (!src) return null; + return ( + + ); +} + export function ProductCard({ product, children, }: { - product: Pick; + product: Pick; children?: React.ReactNode; }) { + const category = product.categories.at(-1)?.name; return ( +
@@ -38,7 +54,7 @@ export function ProductCard({

- {[product.creator && `by ${product.creator}`, `revision ${product.latest}`].filter(Boolean).join(" · ")} + {[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}

{children}
diff --git a/docs/api.md b/docs/api.md index 1ef39e3..77206b5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,13 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `DELETE /api/v1/account/links/:imvu` | unlink | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | +## Products, catalogs and libraries + +The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator, +creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the +latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU +category ID) and `limit` (1-200, default 60). + ## Age verification Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again; diff --git a/docs/products.md b/docs/products.md index 67a88d3..eaf0506 100644 --- a/docs/products.md +++ b/docs/products.md @@ -59,7 +59,7 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS). | `POST /api/v1/catalogs` | register a catalog: `{name, url, contact, visibility ("public"/"private"), membership ("open"/"approval"/"invite")}` → 201 `{catalog, key}` (the key is shown once) | | `GET /api/v1/catalogs` | (anyone) approved public catalogs; the person's own, any status | | `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL | -| `GET /api/v1/products?q=&creator=` | (anyone) products, newest first; adult ones only for people with an adult account | +| `GET /api/v1/products?q=&creator=&category=&limit=` | (anyone) `{products}`, newest first; adult ones only for people with an adult account. `category`: an IMVU category ID; only products whose latest revision's parent has it anywhere in its category path (e.g. 106 Clothing, 128 Tops). `limit`: 1-200, default 60 | | `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` | | `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult | | `POST /api/v1/products/:id/revisions/:n/retract` | (creator) | @@ -72,6 +72,20 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS). The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes. +A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername, +parent, image, categories, createdAt, updatedAt}`. `parent`, `image` and `categories` are the latest revision's: +`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and +`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if +unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-`). + +### Parent info + +The hub looks up a revision's parent when the revision is registered (after it's stored; an upload never waits for or +fails on it) and stores it with the revision (`product_revision.parent_info`: `{found, image, categories, gender, +checkedAt}`; `found` is false when IMVU shows no such product publicly). Info another revision got for the same +parent in the last 7 days is reused. Revisions still without it (IMVU was unavailable) are looked up at startup and +every 10 minutes, 20 at a time. + ### Console (moderators) `GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`. diff --git a/packages/db/drizzle/0004_parent_info.sql b/packages/db/drizzle/0004_parent_info.sql new file mode 100644 index 0000000..197a067 --- /dev/null +++ b/packages/db/drizzle/0004_parent_info.sql @@ -0,0 +1 @@ +ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..e537f62 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1651 @@ +{ + "id": "6c309bf9-4202-40ff-b7d3-45c59f18fea1", + "prevId": "acc90916-2852-4ef8-badc-688e381501cb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 1c1a8f5..444da8c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1793324955738, "tag": "0003_products", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1793347080470, + "tag": "0004_parent_info", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 760c0a6..41a3cde 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import * as schema from "./schema"; export { schema }; -export type { FileEntry } from "./schema"; +export type { FileEntry, ParentInfo } from "./schema"; export * from "drizzle-orm"; const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 560aebb..5c1290e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -226,6 +226,15 @@ export const audit = pgTable( export type FileEntry = { name: string; sha256: string; size: number }; +/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */ +export type ParentInfo = { + found: boolean; // false: IMVU shows no such product publicly + image: string | null; // https, on *.imvu.com + categories: { id: number; name: string }[]; // outermost first + gender: string; // "female", "male" or "" + checkedAt: string; // ISO time +}; + // The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used. export const signingKey = pgTable("signing_key", { id: text("id").primaryKey(), // the JWK's kid @@ -283,6 +292,7 @@ export const productRevision = pgTable( files: jsonb("files").$type().notNull(), rating: text("rating").notNull(), parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from + parentInfo: jsonb("parent_info").$type(), // null until looked up (parent > 0) notes: text("notes").notNull().default(""), token: text("token").notNull(), // the hub's t5de-revision token uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),