feat: file blocks
Moderators block a file network-wide by its sha256, from a product's files or the console's new Blocks tab, which first shows the products using it. Blocking removes those products (their creators are told and may appeal), puts a `block` change in the changes feed of every catalog hosting it, and refuses uploads with it; moderators see who tried. Unblocking lets it in again; the products it removed stay removed until restored. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7299e10b08
commit
ba423f32d8
18 files changed
+4074
-10
No files matched your search
@@ -0,0 +1,106 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
// File blocks, from the console.
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const done = (sha256: string) => {
|
||||
revalidatePath("/console/blocks");
|
||||
revalidatePath(`/console/blocks/${sha256}`);
|
||||
revalidatePath("/console/products");
|
||||
};
|
||||
|
||||
/** Blocks a file network-wide, removing the products using it. */
|
||||
export async function blockFile(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const sha256 = field(form, "sha256");
|
||||
return attempt(async () => {
|
||||
const { removed } = unwrap(await api.blocks({ sha256 }).put({ reason: field(form, "reason") }));
|
||||
done(sha256);
|
||||
return removed.length ? `Blocked. Removed ${removed.length === 1 ? "1 product" : `${removed.length} products`}.` : "Blocked.";
|
||||
});
|
||||
}
|
||||
|
||||
/** Unblocks a file. The products it removed stay removed. */
|
||||
export async function unblockFile(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const sha256 = field(form, "sha256");
|
||||
return attempt(async () => {
|
||||
unwrap(await api.blocks({ sha256 }).delete({ reason: field(form, "reason") }));
|
||||
done(sha256);
|
||||
return "Unblocked.";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user