Products: images need https addresses; lowering a rating waits for review; library shows creators
CI / test (push) Successful in 50s
CI / test (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
51d5bb5ff3
commit
d57674d90c
4 files changed
+14
-17
No files matched your search
+1
-1
@@ -704,7 +704,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),
|
product: productView(l.product, l.creator),
|
||||||
stayOn: l.stayOn,
|
stayOn: l.stayOn,
|
||||||
addedAt: l.addedAt.toISOString(),
|
addedAt: l.addedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const options: sanitize.IOptions = {
|
|||||||
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
|
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Images whose address was refused are dropped.
|
// Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped.
|
||||||
exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src,
|
exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""),
|
||||||
disallowedTagsMode: "discard",
|
disallowedTagsMode: "discard",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -271,16 +271,9 @@ export class Products {
|
|||||||
}
|
}
|
||||||
if (input.rating !== undefined) {
|
if (input.rating !== undefined) {
|
||||||
if (input.rating === "adult") set.rating = "adult";
|
if (input.rating === "adult") set.rating = "adult";
|
||||||
else if (input.rating === "general" && p.rating === "adult") {
|
else if (input.rating === "general" && p.rating === "adult")
|
||||||
// Lowering is reviewed first (design.md); refused for now unless every revision is general.
|
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
|
||||||
const [adultRev] = await this.db
|
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||||
.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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
||||||
return updated!;
|
return updated!;
|
||||||
@@ -301,9 +294,10 @@ 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 })
|
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername })
|
||||||
.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))
|
||||||
.where(eq(library.userId, personId))
|
.where(eq(library.userId, personId))
|
||||||
.orderBy(desc(library.addedAt));
|
.orderBy(desc(library.addedAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
|||||||
|
|
||||||
// Descriptions are cleaned by the allowlist.
|
// Descriptions are cleaned by the allowlist.
|
||||||
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
|
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
|
||||||
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"></p>`,
|
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"><img src="relative.png"><img src="https://img.test/a.png"></p>`,
|
||||||
});
|
});
|
||||||
expect(edited.status).toBe(200);
|
expect(edited.status).toBe(200);
|
||||||
expect(edited.json.product.description).toBe(
|
expect(edited.json.product.description).toBe(
|
||||||
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a></p>`,
|
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a><img src="https://img.test/a.png" /></p>`,
|
||||||
);
|
);
|
||||||
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
|
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.
|
// 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, "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;
|
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(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);
|
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(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"]);
|
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, "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([]);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user