diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8adf287..ad0a955 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -704,7 +704,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), + product: productView(l.product, l.creator), stayOn: l.stayOn, addedAt: l.addedAt.toISOString(), })), diff --git a/apps/api/src/html.ts b/apps/api/src/html.ts index 75a5006..c23d8b2 100644 --- a/apps/api/src/html.ts +++ b/apps/api/src/html.ts @@ -41,8 +41,8 @@ const options: sanitize.IOptions = { attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" }, }), }, - // Images whose address was refused are dropped. - exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src, + // Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped. + exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""), disallowedTagsMode: "discard", }; diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index 8f19f17..2a47844 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -271,16 +271,9 @@ export class Products { } if (input.rating !== undefined) { if (input.rating === "adult") set.rating = "adult"; - else if (input.rating === "general" && p.rating === "adult") { - // Lowering is reviewed first (design.md); refused for now unless every revision is general. - const [adultRev] = await this.db - .select({ n: productRevision.number }) - .from(productRevision) - .where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"))) - .limit(1); - if (adultRev) throw new ProductError(400, "invalid", "A product with adult revisions stays adult."); - set.rating = "general"; - } + else if (input.rating === "general" && p.rating === "adult") + // Lowering is reviewed by moderators first (design.md); until that exists, it's refused. + throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review."); } const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning(); return updated!; @@ -301,9 +294,10 @@ export class Products { async library(personId: string) { return this.db - .select({ product, stayOn: library.stayOn, addedAt: library.addedAt }) + .select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername }) .from(library) .innerJoin(product, eq(product.id, library.productId)) + .innerJoin(user, eq(user.id, product.creatorId)) .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 1cc9a16..77dc521 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -119,11 +119,11 @@ test("catalogs register uploads; products have revisions, info and libraries", a // Descriptions are cleaned by the allowlist. const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, { - description: `
`, + description: ``, }); expect(edited.status).toBe(200); expect(edited.json.product.description).toBe( - ``, + ``, ); expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403); @@ -138,7 +138,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a // The library: get it, the proxy resolves it to the latest, or the revision the person stays on. expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200); - expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]); + expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => [l.product.id, l.product.creator])).toEqual([[id, "maker"]]); let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json; expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] }); expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200); @@ -165,6 +165,9 @@ test("catalogs register uploads; products have revisions, info and libraries", a expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]); expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]); + // Lowering a rating waits for review. + expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed"); + expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204); expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]); });