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]>
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import { PageHeader } from "@/components/status";
|
|
import { DateTime } from "@/components/time";
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { api, unwrap } from "@/lib/api";
|
|
import { ConsoleNav, requireConsole } from "../console-nav";
|
|
|
|
export const metadata: Metadata = { title: "Blocks" };
|
|
|
|
export default async function BlocksPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
|
const { console: c } = await requireConsole("/console/blocks");
|
|
if (!c.sections.includes("blocks")) notFound();
|
|
const params = await searchParams;
|
|
// Looking a file up goes to its page.
|
|
if (typeof params.sha256 === "string") {
|
|
const sha256 = params.sha256.trim().toLowerCase();
|
|
if (/^[0-9a-f]{64}$/.test(sha256)) redirect(`/console/blocks/${sha256}`);
|
|
}
|
|
const before = typeof params.before === "string" && !Number.isNaN(Date.parse(params.before)) ? params.before : undefined;
|
|
const data = unwrap(await api.blocks.get({ query: before ? { before } : {} }));
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Blocks">Blocked files: uploads with them are refused, and catalogs delete them.</PageHeader>
|
|
<ConsoleNav />
|
|
<form action="/console/blocks" className="mb-6 flex flex-wrap gap-2">
|
|
<Input
|
|
name="sha256"
|
|
required
|
|
pattern="\s*[0-9a-fA-F]{64}\s*"
|
|
defaultValue={typeof params.sha256 === "string" ? params.sha256 : ""}
|
|
placeholder="A file's sha256"
|
|
aria-label="sha256"
|
|
className="max-w-xl font-mono"
|
|
/>
|
|
<Button type="submit">Look up</Button>
|
|
</form>
|
|
<Card>
|
|
<CardContent>
|
|
{data.blocks.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nothing blocked. Block a file from a product's files, or look it up above.
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>File</TableHead>
|
|
<TableHead>Reason</TableHead>
|
|
<TableHead>By</TableHead>
|
|
<TableHead>When</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.blocks.map((b) => (
|
|
<TableRow key={b.sha256}>
|
|
<TableCell className="font-mono text-xs">
|
|
<Link href={`/console/blocks/${b.sha256}`} className="underline-offset-4 hover:underline">
|
|
{b.sha256.slice(0, 16)}…
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="whitespace-normal">{b.reason}</TableCell>
|
|
<TableCell className="text-muted-foreground">{b.by ?? ""}</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
<DateTime iso={b.at} />
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
{data.before && (
|
|
<div className="mt-4">
|
|
<Link href={`/console/blocks?before=${encodeURIComponent(data.before)}`} className={buttonVariants({ variant: "outline" })}>
|
|
Older
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|