A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it leaves libraries and rooms. Both are on the product's page and in the audit log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07bbc11565
commit
97ce2be569
12 files changed
+2435
-14
No files matched your search
@@ -268,6 +268,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
parent: latest?.parent ?? 0,
|
||||
image: latest?.parentInfo?.image ?? null,
|
||||
categories: latest?.parentInfo?.categories ?? [],
|
||||
deleted: p.deletedAt !== null,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -1155,6 +1156,26 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
}),
|
||||
},
|
||||
)
|
||||
// Its creator deletes a product (no longer listed or added; those who have it keep it).
|
||||
.delete(
|
||||
"/products/:id",
|
||||
async ({ me, params, set }) => {
|
||||
await productCall(() => products.delete(me.id, params.id));
|
||||
await audited(me.id, "product.delete", params.id);
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: productParam },
|
||||
)
|
||||
// Moderation removes a product: gone everywhere.
|
||||
.post(
|
||||
"/products/:id/remove",
|
||||
async ({ me, params, body }) => {
|
||||
await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
|
||||
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() });
|
||||
return { ok: true };
|
||||
},
|
||||
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
||||
)
|
||||
.post(
|
||||
"/products/:id/revisions/:n/retract",
|
||||
async ({ me, params }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
|
||||
// room is realtime.ts's decorated().
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, inArray, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
|
||||
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
|
||||
import { hostedBase } from "./realtime";
|
||||
|
||||
const { roomDecoration, product, productRevision, imvuLink } = schema;
|
||||
@@ -55,6 +55,7 @@ export class Decorations {
|
||||
and(
|
||||
inArray(product.id, ids),
|
||||
ne(product.latest, 0),
|
||||
isNull(product.removedAt),
|
||||
adult ? undefined : eq(product.rating, "general"),
|
||||
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
|
||||
),
|
||||
@@ -93,7 +94,7 @@ export class Decorations {
|
||||
const known = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(inArray(product.id, ids), ne(product.latest, 0)));
|
||||
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
|
||||
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
|
||||
}
|
||||
if (contents.length === 0) {
|
||||
|
||||
@@ -296,6 +296,25 @@ export class Products {
|
||||
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
|
||||
if (!p) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
|
||||
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
|
||||
async delete(personId: string, id: string) {
|
||||
await this.own(personId, id);
|
||||
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
|
||||
}
|
||||
|
||||
/** Moderation removes a product: gone everywhere. */
|
||||
async removeByModeration(id: string, reason: string) {
|
||||
const [p] = await this.db
|
||||
.update(product)
|
||||
.set({ removedAt: new Date(), removedReason: reason })
|
||||
.where(and(eq(product.id, id), isNull(product.removedAt)))
|
||||
.returning();
|
||||
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -304,7 +323,7 @@ export class Products {
|
||||
* 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), isNull(product.deletedAt), isNull(product.removedAt)];
|
||||
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()));
|
||||
@@ -343,6 +362,7 @@ export class Products {
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(product.id, id));
|
||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
const revisions = await this.db
|
||||
.select()
|
||||
@@ -407,7 +427,7 @@ export class Products {
|
||||
.innerJoin(product, eq(product.id, library.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(eq(library.userId, personId))
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -420,7 +440,9 @@ export class Products {
|
||||
}
|
||||
|
||||
async add(personId: string, productId: string, adult: boolean) {
|
||||
await this.get(productId, adult);
|
||||
const p = await this.get(productId, adult);
|
||||
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
|
||||
throw new ProductError(410, "deleted", "Its creator deleted this product.");
|
||||
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
|
||||
@@ -278,3 +278,38 @@ test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () =
|
||||
expect(user("https://evil.example/p.png").picture).toBeNull();
|
||||
expect(user(undefined).picture).toBeNull();
|
||||
});
|
||||
|
||||
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
|
||||
const { admin, catalogID, auth } = shared;
|
||||
const maker = await person("deleter", { id: 511, ageVerified: true });
|
||||
const owner = await person("keeper", { id: 512, ageVerified: true });
|
||||
const late = await person("latecomer", { id: 513, ageVerified: true });
|
||||
const make = async (name: string) => {
|
||||
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
|
||||
return r.json.product as string;
|
||||
};
|
||||
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
|
||||
const id = await make("Gone Hat");
|
||||
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
||||
|
||||
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
|
||||
// the files stay for those who have it.
|
||||
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
|
||||
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
|
||||
expect(await listed()).toEqual([]);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
|
||||
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
|
||||
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
|
||||
|
||||
// Moderation removes one: no page, no files, out of libraries.
|
||||
const bad = await make("Gone Shoes");
|
||||
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
|
||||
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
|
||||
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
|
||||
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||
});
|
||||
@@ -6,8 +6,9 @@ import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getMe, type Revision } from "@/lib/api";
|
||||
import { libraryAction, retract } from "../actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getMe, isModerator, type Revision } from "@/lib/api";
|
||||
import { deleteProduct, libraryAction, removeProduct, retract } from "../actions";
|
||||
import { loadProduct } from "../load";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
@@ -27,9 +28,16 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{product.creator && <span>by {product.creator}</span>}
|
||||
<RatingTag rating={product.rating} />
|
||||
{product.deleted && <Tag tone="danger">deleted</Tag>}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
{product.deleted && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(product.image || product.categories.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{product.image && (
|
||||
@@ -44,7 +52,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{me ? (
|
||||
{product.deleted && !inLibrary ? null : me ? (
|
||||
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{inLibrary ? (
|
||||
@@ -66,10 +74,21 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Sign in to get it
|
||||
</Link>
|
||||
)}
|
||||
{mine && (
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
Edit
|
||||
</Link>
|
||||
{mine && !product.deleted && (
|
||||
<>
|
||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||
Edit
|
||||
</Link>
|
||||
<ActionForm action={deleteProduct} fields={{ id }}>
|
||||
<SubmitButton
|
||||
variant="outline"
|
||||
pendingLabel="Deleting…"
|
||||
confirm="Delete this product? Nobody new can get it. People who have it keep it."
|
||||
>
|
||||
Delete
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -82,6 +101,26 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isModerator(me) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moderation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={removeProduct} fields={{ id }} className="flex flex-wrap items-center gap-2">
|
||||
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
pendingLabel="Removing…"
|
||||
confirm="Remove this product everywhere? Its page, files and library entries go."
|
||||
>
|
||||
Remove
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revisions</CardTitle>
|
||||
|
||||
@@ -32,6 +32,30 @@ export async function libraryAction(_: ActionState, form: FormData): Promise<Act
|
||||
});
|
||||
}
|
||||
|
||||
/** Its creator deletes the product. */
|
||||
export async function deleteProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(await api.products({ id }).delete());
|
||||
done(id);
|
||||
revalidatePath("/products");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect(`/products/${id}`);
|
||||
}
|
||||
|
||||
/** Moderation removes the product. */
|
||||
export async function removeProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(await api.products({ id }).remove.post({ reason: field(form, "reason") }));
|
||||
done(id);
|
||||
revalidatePath("/products");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/products");
|
||||
}
|
||||
|
||||
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
|
||||
@@ -2,13 +2,13 @@ import "server-only";
|
||||
import { notFound } from "next/navigation";
|
||||
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
|
||||
|
||||
/** The product, or not found (also when it's adult and the visitor can't see adult products). */
|
||||
/** The product, or not found (also when it's adult and the visitor can't see adult products, or it was removed). */
|
||||
export async function loadProduct(id: string): Promise<ProductPage> {
|
||||
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
|
||||
try {
|
||||
return unwrap(await api.products({ id }).get());
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user