Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.
**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
- removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
- puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
- refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.
**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).
**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.
**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
- who may block
- the preview of what a block removes
- removal of every product using the file, across catalogs and creators
- the changes feed, notifications and appeals
- refused uploads and their audit entry
- unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
- the Block link on a product's files, for moderators only
- the Blocks tab, for moderators only (404 for the creator)
- the file's page, then blocking: the product is gone, its creator notified and offered Appeal
- a re-upload through the catalog API: refused, and audited
- unblocking, then Restore
- Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.
**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
- removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
- puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
- refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.
**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).
**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.
**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
- who may block
- the preview of what a block removes
- removal of every product using the file, across catalogs and creators
- the changes feed, notifications and appeals
- refused uploads and their audit entry
- unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
- the Block link on a product's files, for moderators only
- the Blocks tab, for moderators only (404 for the creator)
- the file's page, then blocking: the product is gone, its creator notified and offered Appeal
- a re-upload through the catalog API: refused, and audited
- unblocking, then Restore
- Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
107 lines
4.6 KiB
TypeScript
107 lines
4.6 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
|
import { PageHeader, Tag } from "@/components/status";
|
|
import { DateTime } from "@/components/time";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { api, unwrap } from "@/lib/api";
|
|
import { ConsoleNav, requireConsole } from "../../console-nav";
|
|
import { blockFile, unblockFile } from "../actions";
|
|
|
|
export const metadata: Metadata = { title: "Block" };
|
|
|
|
/** A file: whether it's blocked, the products using it, and blocking or unblocking it. */
|
|
export default async function BlockPage({ params }: { params: Promise<{ sha256: string }> }) {
|
|
const { sha256 } = await params;
|
|
if (!/^[0-9a-f]{64}$/.test(sha256)) notFound();
|
|
const { console: c } = await requireConsole(`/console/blocks/${sha256}`);
|
|
if (!c.sections.includes("blocks")) notFound();
|
|
const { block, products } = unwrap(await api.blocks({ sha256 }).get());
|
|
const live = products.filter((p) => !p.removed);
|
|
const removeCount = live.length === 1 ? "1 product" : `${live.length} products`;
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="File">
|
|
<span className="font-mono text-xs break-all">{sha256}</span>
|
|
</PageHeader>
|
|
<ConsoleNav />
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{block ? "Blocked" : "Not Blocked"}</CardTitle>
|
|
<CardDescription>
|
|
{block ? (
|
|
<>
|
|
By {block.by ?? "a former moderator"}, <DateTime iso={block.at} />: {block.reason}
|
|
</>
|
|
) : (
|
|
"Blocking it removes the products below, tells their catalogs to delete it, and refuses uploads with it."
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{block ? (
|
|
<ActionForm action={unblockFile} fields={{ sha256 }}>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input name="reason" maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
|
<SubmitButton
|
|
variant="outline"
|
|
pendingLabel="Unblocking…"
|
|
confirm="Unblock this file? The products it removed stay removed until restored."
|
|
>
|
|
Unblock
|
|
</SubmitButton>
|
|
</div>
|
|
</ActionForm>
|
|
) : (
|
|
<ActionForm action={blockFile} fields={{ sha256 }}>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input name="reason" required maxLength={500} placeholder="Reason (creators see it)" aria-label="Reason" className="max-w-sm" />
|
|
<SubmitButton
|
|
variant="destructive"
|
|
pendingLabel="Blocking…"
|
|
confirm={live.length ? `Block this file and remove ${removeCount}?` : "Block this file?"}
|
|
>
|
|
{live.length ? `Block and remove ${removeCount}` : "Block"}
|
|
</SubmitButton>
|
|
</div>
|
|
</ActionForm>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Used By</CardTitle>
|
|
<CardDescription>Products with a revision that has this file.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{products.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No product uses it.</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{products.map((p) => (
|
|
<li key={p.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0">
|
|
<Link
|
|
href={p.removed ? `/console/products?q=${encodeURIComponent(p.name)}&status=removed&all=1` : `/products/${p.id}`}
|
|
className="font-medium underline-offset-4 hover:underline"
|
|
>
|
|
{p.name}
|
|
</Link>
|
|
<span className="text-sm text-muted-foreground">
|
|
by {p.creatorUsername ?? "someone"} · {p.revisions.length === 1 ? "revision" : "revisions"} {p.revisions.join(", ")}
|
|
</span>
|
|
{p.removed && <Tag tone="danger">removed</Tag>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|