Revisions with an IMVU parent store its public info (image, category path, gender) from IMVU's product API, looked up after registering and in a periodic pass. Product views gain parent, image and categories; GET /products takes category and limit. The website shows the image on cards and product pages. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d57674d90c
commit
6668b55117
15 files changed
+2026
-29
No files matched your search
@@ -26,4 +26,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
|
|||||||
(`t5de-proxy...`).
|
(`t5de-proxy...`).
|
||||||
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
|
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
|
||||||
`bunx`: use `bun x`).
|
`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).
|
||||||
+21
-8
@@ -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
|
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
|
||||||
// token.
|
// token.
|
||||||
import { Elysia, t } from "elysia";
|
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 type { Auth } from "./auth";
|
||||||
import { AgeError, AgeService } from "./age";
|
import { AgeError, AgeService } from "./age";
|
||||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
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) {
|
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) {
|
||||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||||
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
|
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 rooms = roomsService ?? new Rooms(db, imvu);
|
||||||
const realtime =
|
const realtime =
|
||||||
rt ??
|
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 = (
|
const productView = (
|
||||||
p: typeof schema.product.$inferSelect,
|
p: typeof schema.product.$inferSelect,
|
||||||
creator?: string | null,
|
creator?: string | null,
|
||||||
creatorUsername?: string | null,
|
creatorUsername?: string | null,
|
||||||
|
latest?: { parent: number | null; parentInfo: ParentInfo | null } | null,
|
||||||
) => ({
|
) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -190,6 +192,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
latest: p.latest,
|
latest: p.latest,
|
||||||
creator: creator ?? creatorUsername ?? "",
|
creator: creator ?? creatorUsername ?? "",
|
||||||
creatorUsername: creatorUsername ?? "",
|
creatorUsername: creatorUsername ?? "",
|
||||||
|
parent: latest?.parent ?? 0,
|
||||||
|
image: latest?.parentInfo?.image ?? null,
|
||||||
|
categories: latest?.parentInfo?.categories ?? [],
|
||||||
createdAt: p.createdAt.toISOString(),
|
createdAt: p.createdAt.toISOString(),
|
||||||
updatedAt: p.updatedAt.toISOString(),
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
@@ -616,10 +621,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
"/products",
|
"/products",
|
||||||
async ({ person, query }) => {
|
async ({ person, query }) => {
|
||||||
const adult = await personAdult(person);
|
const adult = await personAdult(person);
|
||||||
const rows = await products.list({ q: query.q, creator: query.creator, adult });
|
const rows = await products.list({ ...query, adult });
|
||||||
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) };
|
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(
|
.get(
|
||||||
"/products/:id",
|
"/products/:id",
|
||||||
@@ -627,8 +639,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
const adult = await personAdult(person);
|
const adult = await personAdult(person);
|
||||||
const p = await productCall(() => products.get(params.id, adult));
|
const p = await productCall(() => products.get(params.id, adult));
|
||||||
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
|
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
|
||||||
|
const latest = p.revisions.find((r) => r.number === p.product.latest);
|
||||||
return {
|
return {
|
||||||
product: productView(p.product, p.creator, p.creatorUsername),
|
product: productView(p.product, p.creator, p.creatorUsername, latest),
|
||||||
revisions: p.revisions.map((r) => ({
|
revisions: p.revisions.map((r) => ({
|
||||||
number: r.number,
|
number: r.number,
|
||||||
rating: r.rating,
|
rating: r.rating,
|
||||||
@@ -651,7 +664,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
async ({ me, params, body }) => {
|
async ({ me, params, body }) => {
|
||||||
const p = await productCall(() => products.update(me.id, params.id, body));
|
const p = await productCall(() => products.update(me.id, params.id, body));
|
||||||
await audited(me.id, "product.edit", p.id, { fields: Object.keys(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,
|
signedIn: true,
|
||||||
@@ -704,7 +717,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
"/library",
|
"/library",
|
||||||
async ({ me }) => ({
|
async ({ me }) => ({
|
||||||
library: (await products.library(me.id)).map((l) => ({
|
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,
|
stayOn: l.stayOn,
|
||||||
addedAt: l.addedAt.toISOString(),
|
addedAt: l.addedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
+56
-4
@@ -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;
|
// 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
|
// rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
|
||||||
// on it for nothing else.
|
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
|
||||||
|
|
||||||
export type ImvuUser = {
|
export type ImvuUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,6 +25,13 @@ export type ImvuRoom = {
|
|||||||
expired: boolean;
|
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 {
|
export class NoSuchImvuUser extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("no such IMVU account");
|
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<ImvuProduct | null> {
|
||||||
|
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() {
|
private async wait() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const at = Math.max(now, this.next);
|
const at = Math.max(now, this.next);
|
||||||
@@ -72,7 +93,8 @@ export class ImvuClient {
|
|||||||
if (at > now) await Bun.sleep(at - now);
|
if (at > now) await Bun.sleep(at - now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async get(path: string): Promise<unknown> {
|
/** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
|
||||||
|
private async get(path: string, missing: number[] = []): Promise<unknown> {
|
||||||
await this.wait();
|
await this.wait();
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +105,7 @@ export class ImvuClient {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ImvuUnavailable(String(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}`);
|
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||||
return response.json();
|
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<string, { data?: Record<string, unknown> }> })?.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-<id> entry.
|
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||||
export function parse(answer: unknown): ImvuUser {
|
export function parse(answer: unknown): ImvuUser {
|
||||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
|||||||
|
|
||||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
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" });
|
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(() => {
|
setInterval(() => {
|
||||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||||
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||||
|
parentInfo();
|
||||||
}, 10 * 60 * 1000);
|
}, 10 * 60 * 1000);
|
||||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||||
+116
-7
@@ -1,7 +1,23 @@
|
|||||||
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
|
// 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 { 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 { cleanHTML, maxDescription } from "./html";
|
||||||
|
import type { ImvuClient } from "./imvu";
|
||||||
import type { Signer } from "./keys";
|
import type { Signer } from "./keys";
|
||||||
|
|
||||||
const { catalog, product, productRevision, hosting, library, user } = schema;
|
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[]) =>
|
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.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 {
|
export class Products {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Database,
|
private readonly db: Database,
|
||||||
private readonly signer: Signer,
|
private readonly signer: Signer,
|
||||||
|
private readonly imvu?: ImvuClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---- catalogs ----
|
// ---- catalogs ----
|
||||||
@@ -164,7 +187,7 @@ export class Products {
|
|||||||
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
||||||
const personId = claims.sub;
|
const personId = claims.sub;
|
||||||
|
|
||||||
return this.db.transaction(async (tx) => {
|
const made = await this.db.transaction(async (tx) => {
|
||||||
let p: Product;
|
let p: Product;
|
||||||
if (typeof claims.product === "string") {
|
if (typeof claims.product === "string") {
|
||||||
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
|
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 });
|
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
|
||||||
return { product: p.id, revision: 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<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 ----
|
// ---- products ----
|
||||||
@@ -220,19 +299,41 @@ export class Products {
|
|||||||
return p;
|
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)];
|
const conds = [ne(product.latest, 0)];
|
||||||
if (!opts.adult) conds.push(eq(product.rating, "general"));
|
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.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.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
|
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)
|
.from(product)
|
||||||
.innerJoin(user, eq(user.id, product.creatorId))
|
.innerJoin(user, eq(user.id, product.creatorId))
|
||||||
|
.leftJoin(productRevision, latestJoin)
|
||||||
.where(and(...conds))
|
.where(and(...conds))
|
||||||
.orderBy(desc(product.createdAt))
|
.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) {
|
async get(id: string, adult: boolean) {
|
||||||
@@ -294,10 +395,18 @@ export class Products {
|
|||||||
|
|
||||||
async library(personId: string) {
|
async library(personId: string) {
|
||||||
return this.db
|
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)
|
.from(library)
|
||||||
.innerJoin(product, eq(product.id, library.productId))
|
.innerJoin(product, eq(product.id, library.productId))
|
||||||
.innerJoin(user, eq(user.id, product.creatorId))
|
.innerJoin(user, eq(user.id, product.creatorId))
|
||||||
|
.leftJoin(productRevision, latestJoin)
|
||||||
.where(eq(library.userId, personId))
|
.where(eq(library.userId, personId))
|
||||||
.orderBy(desc(library.addedAt));
|
.orderBy(desc(library.addedAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ import { createLocalJWKSet, jwtVerify } from "jose";
|
|||||||
import { openDatabase, type Database } from "@t5de/db";
|
import { openDatabase, type Database } from "@t5de/db";
|
||||||
import { createApp } from "../src/app";
|
import { createApp } from "../src/app";
|
||||||
import { createAuth } from "../src/auth";
|
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 webURL = "http://web.test";
|
||||||
const apiURL = "http://api.test";
|
const apiURL = "http://api.test";
|
||||||
const accounts = new Map<number, ImvuUser>();
|
const accounts = new Map<number, ImvuUser>();
|
||||||
|
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
|
||||||
|
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
|
||||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||||
const url = new URL(String(input));
|
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(
|
const u = [...accounts.values()].find(
|
||||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
(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<void>;
|
let close: () => Promise<void>;
|
||||||
let base: string;
|
let base: string;
|
||||||
let stop: () => void;
|
let stop: () => void;
|
||||||
|
let hub: ReturnType<typeof createApp>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ db, close } = await openDatabase("memory"));
|
({ db, close } = await openDatabase("memory"));
|
||||||
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
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" });
|
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||||
stop = () => server.stop();
|
stop = () => server.stop();
|
||||||
@@ -74,6 +85,9 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean })
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by the first test for the next: the admin and an approved catalog.
|
||||||
|
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
|
||||||
|
|
||||||
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
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 () => {
|
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);
|
expect(reg.status).toBe(201);
|
||||||
const catalogID = reg.json.catalog.id as string;
|
const catalogID = reg.json.catalog.id as string;
|
||||||
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
|
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
|
||||||
|
shared = { admin, catalogID, auth };
|
||||||
expect(reg.json.catalog.url).toBe("http://localhost:4102");
|
expect(reg.json.catalog.url).toBe("http://localhost:4102");
|
||||||
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
|
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);
|
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, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
|
||||||
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
|
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://[email protected]/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 });
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
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 { PageHeader, Tag } from "@/components/status";
|
||||||
import { DateTime } from "@/components/time";
|
import { DateTime } from "@/components/time";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -30,6 +30,19 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{(product.image || product.categories.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{product.image && (
|
||||||
|
<div className="w-40 overflow-hidden rounded-xl ring-1 ring-foreground/10">
|
||||||
|
<ProductImage src={product.image} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.categories.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{product.categories.map((c) => c.name).join(" › ")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{me ? (
|
{me ? (
|
||||||
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||||
|
|||||||
@@ -21,15 +21,31 @@ export function formatSize(bytes: number): string {
|
|||||||
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
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 (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
className={`aspect-[5/4] w-full bg-muted object-contain ${className ?? ""}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
product,
|
product,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest">;
|
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories">;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const category = product.categories.at(-1)?.name;
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
|
<ProductImage src={product.image} />
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||||
@@ -38,7 +54,7 @@ export function ProductCard({
|
|||||||
<RatingTag rating={product.rating} />
|
<RatingTag rating={product.rating} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{[product.creator && `by ${product.creator}`, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
|||||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
| `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
|
## Age verification
|
||||||
|
|
||||||
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
|
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
|
||||||
|
|||||||
+15
-1
@@ -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) |
|
| `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 |
|
| `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 |
|
| `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?}` |
|
| `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 |
|
| `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) |
|
| `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.
|
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-<id>`).
|
||||||
|
|
||||||
|
### 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)
|
### Console (moderators)
|
||||||
|
|
||||||
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
|
`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "product_revision" ADD COLUMN "parent_info" jsonb;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1793324955738,
|
"when": 1793324955738,
|
||||||
"tag": "0003_products",
|
"tag": "0003_products",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793347080470,
|
||||||
|
"tag": "0004_parent_info",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
export type { FileEntry } from "./schema";
|
export type { FileEntry, ParentInfo } from "./schema";
|
||||||
export * from "drizzle-orm";
|
export * from "drizzle-orm";
|
||||||
|
|
||||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ export const audit = pgTable(
|
|||||||
|
|
||||||
export type FileEntry = { name: string; sha256: string; size: number };
|
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.
|
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
|
||||||
export const signingKey = pgTable("signing_key", {
|
export const signingKey = pgTable("signing_key", {
|
||||||
id: text("id").primaryKey(), // the JWK's kid
|
id: text("id").primaryKey(), // the JWK's kid
|
||||||
@@ -283,6 +292,7 @@ export const productRevision = pgTable(
|
|||||||
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
||||||
rating: text("rating").notNull(),
|
rating: text("rating").notNull(),
|
||||||
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
||||||
|
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
|
||||||
notes: text("notes").notNull().default(""),
|
notes: text("notes").notNull().default(""),
|
||||||
token: text("token").notNull(), // the hub's t5de-revision token
|
token: text("token").notNull(), // the hub's t5de-revision token
|
||||||
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
||||||
|
|||||||
Reference in new issue
Block a user