feat(admin): word/link/hashtag blacklist module (Gitea #10 Phase 3, part 3/4)

Adds a BlacklistEntry model (type WORD|LINK|HASHTAG, unique per
type+value) with a new admin module at /p/[secret]/blacklist —
add/list/delete entries, filterable by type.

Enforcement, per explicit spec from this session:
- New posts are checked at creation time (posts.create) against the
  current blacklist (whole-word regex match for WORD, domain match
  incl. subdomains for LINK, exact match for HASHTAG) and auto-hidden
  immediately via the existing hiddenAt mechanism if matched — never
  blocks creation, just hides right after, reversible via
  admin.unhidePost. Logged as HIDE_POST with moderatorId "SYSTEM".
- Adding a new entry retroactively scans existing non-hidden posts
  (capped at 5000 for safety) and hides matches too, logged per-post
  under the admin who added the entry.
- Removing an entry does NOT auto-unhide already-hidden posts —
  that stays a manual review step via the existing hide/unhide UI.

Matching lives in src/lib/blacklist.ts: word matching uses \b word
boundaries (a blacklisted "ass" won't match "assist"), link matching
extracts URLs and compares hostnames including subdomains.

Scope: post captions/hashtags only, not comment bodies — comments
don't have a hiddenAt field yet, so this is a natural follow-up once
they do, using the same pattern.

Gitea #10
This commit is contained in:
admin committed 2026-08-12 16:06:37 +02:00
1 parent eb26cb5fbe
commit 7395d83194
7 files changed
+440 -1

No files matched your search

+25
View File
@@ -614,6 +614,31 @@ model AdminRole {
grantedBy String? // ownerId of the granting super admin
}
enum BlacklistEntryType {
WORD
LINK
HASHTAG
}
// Retroactive-capable content filter (Gitea #10 Phase 3) — adding an entry
// scans existing non-hidden posts for a match and auto-hides them via the
// same Post.hiddenAt mechanism a human moderator uses (admin.hidePost),
// stamped with a HIDE_POST ModerationLog entry so it's auditable exactly
// like a manual hide. New posts are checked at creation time too
// (posts.create) and hidden immediately if they match. Removing an entry
// does NOT auto-unhide already-hidden posts — that stays a manual review
// step via the existing hide/unhide UI.
model BlacklistEntry {
id String @id @default(cuid())
type BlacklistEntryType
value String @db.VarChar(200) // normalized lowercase: word, link domain, or hashtag (no #)
createdAt DateTime @default(now())
createdBy String // ownerId of the moderator who added it
@@unique([type, value])
@@index([type])
}
model UserBan {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
+24
View File
@@ -141,6 +141,18 @@ interface MockUserBanClient {
findUnique: MockFn;
}
interface MockShadowbanClient {
upsert: MockFn;
delete: MockFn;
findUnique: MockFn;
}
interface MockBlacklistEntryClient {
findMany: MockFn;
upsert: MockFn;
delete: MockFn;
}
// ─── Phase 3 mock interfaces ──────────────────────────────────────────────────
interface MockReactionClient {
@@ -261,6 +273,8 @@ interface MockPrismaClient {
block: MockBlockClient;
report: MockReportClient;
userBan: MockUserBanClient;
shadowban: MockShadowbanClient;
blacklistEntry: MockBlacklistEntryClient;
// Phase 3
reaction: MockReactionClient;
comment: MockCommentClient;
@@ -398,6 +412,16 @@ export function createMockPrisma(): MockPrismaClient {
delete: vi.fn(),
findUnique: vi.fn(),
},
shadowban: {
upsert: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
},
blacklistEntry: {
findMany: vi.fn().mockResolvedValue([]),
upsert: vi.fn(),
delete: vi.fn(),
},
// Phase 3 mocks
reaction: {
findUnique: vi.fn(),
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Trash2, Loader2, Plus, Ban } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { BlacklistEntryType } from "@prisma/client";
const TYPE_LABEL: Record<BlacklistEntryType, string> = {
WORD: "Word",
LINK: "Link",
HASHTAG: "Hashtag",
};
const TYPE_BADGE_CLASS: Record<BlacklistEntryType, string> = {
WORD: "bg-amber-950/60 text-amber-300",
LINK: "bg-sky-950/60 text-sky-300",
HASHTAG: "bg-fuchsia-950/60 text-fuchsia-300",
};
type TypeFilter = "all" | BlacklistEntryType;
export default function AdminBlacklistPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [newType, setNewType] = useState<BlacklistEntryType>(BlacklistEntryType.WORD);
const [newValue, setNewValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const { data: entries = [], isLoading } = useQuery(
trpc.admin.listBlacklist.queryOptions(
typeFilter === "all" ? {} : { type: typeFilter }
)
);
const createMutation = useMutation(
trpc.admin.createBlacklistEntry.mutationOptions({
onSuccess: ({ hiddenCount }) => {
void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() });
setNewValue("");
if (hiddenCount > 0) {
toast.success(`Added — ${hiddenCount} existing post${hiddenCount !== 1 ? "s" : ""} hidden retroactively.`);
} else {
toast.success("Blacklist entry added.");
}
},
onError: (err) => {
toast.error(`Failed to add entry: ${err.message}`);
},
})
);
const deleteMutation = useMutation(
trpc.admin.deleteBlacklistEntry.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() });
setDeleteTarget(null);
},
})
);
function handleCreate() {
const value = newValue.trim();
if (!value) return;
createMutation.mutate({ type: newType, value });
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Blacklist</h1>
<p className="text-sm text-zinc-500 mt-1">
Words, links, and hashtags that auto-hide matching posts — new posts are checked on
creation, and adding an entry here retroactively scans + hides existing matches.
</p>
</div>
{/* Add entry */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900 p-3">
<Select value={newType} onValueChange={(v) => setNewType(v as BlacklistEntryType)}>
<SelectTrigger className="w-32 bg-zinc-800 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
<SelectItem value={BlacklistEntryType.HASHTAG}>Hashtag</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={
newType === "WORD" ? "word…" : newType === "LINK" ? "domain, e.g. spam.example" : "hashtag (without #)"
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="flex-1 min-w-[200px] bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Button
onClick={handleCreate}
disabled={createMutation.isPending || !newValue.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
) : (
<Plus className="h-4 w-4 mr-1.5" />
)}
Add
</Button>
</div>
{/* Filter */}
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="all">All types</SelectItem>
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
<SelectItem value={BlacklistEntryType.HASHTAG}>Hashtag</SelectItem>
</SelectContent>
</Select>
{/* List */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12 text-zinc-500">
<Ban className="h-8 w-8" aria-hidden="true" />
<p className="text-sm">No blacklist entries yet.</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<Badge variant="secondary" className={`text-[10px] shrink-0 ${TYPE_BADGE_CLASS[entry.type]}`}>
{TYPE_LABEL[entry.type]}
</Badge>
<span className="flex-1 min-w-0 truncate text-sm text-zinc-100 font-mono">
{entry.type === "HASHTAG" ? `#${entry.value}` : entry.value}
</span>
<span className="shrink-0 text-xs text-zinc-500">
{new Date(entry.createdAt).toLocaleDateString()}
</span>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
onClick={() => setDeleteTarget(entry.id)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && deleteMutation.variables?.id === entry.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
)}
<Dialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Remove blacklist entry</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
Already-hidden posts stay hidden — this only stops future matches.
</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate({ id: deleteTarget! })}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -13,6 +13,7 @@ import {
Scale,
ScrollText,
BadgeCheck,
Ban,
} from "lucide-react";
interface AdminLayoutProps {
@@ -61,6 +62,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
? [
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
]
: []),
];
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
/** Extracts http(s) URLs from free text. */
export function extractLinks(text: string): string[] {
return text.match(/https?:\/\/[^\s]+/gi) ?? [];
}
function normalizeDomain(urlOrDomain: string): string {
try {
const url = new URL(urlOrDomain.startsWith("http") ? urlOrDomain : `https://${urlOrDomain}`);
return url.hostname.replace(/^www\./, "").toLowerCase();
} catch {
return urlOrDomain.toLowerCase().replace(/^www\./, "");
}
}
/** Whole-word, case-insensitive match — avoids "assist" matching a blacklisted "ass". */
export function matchesWordBlacklist(text: string, words: string[]): string | null {
for (const word of words) {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`\\b${escaped}\\b`, "i").test(text)) return word;
}
return null;
}
/** Matches a blacklisted domain, or any of its subdomains. */
export function matchesLinkBlacklist(text: string, domains: string[]): string | null {
const linkDomains = extractLinks(text).map(normalizeDomain);
for (const domain of domains) {
const normalized = normalizeDomain(domain);
if (linkDomains.some((d) => d === normalized || d.endsWith(`.${normalized}`))) return domain;
}
return null;
}
export function matchesHashtagBlacklist(hashtags: string[], blacklisted: string[]): string | null {
const lowerTags = new Set(hashtags.map((t) => t.toLowerCase()));
for (const tag of blacklisted) {
if (lowerTags.has(tag.toLowerCase())) return tag;
}
return null;
}
export interface BlacklistEntryLike {
type: "WORD" | "LINK" | "HASHTAG";
value: string;
}
export interface BlacklistCheckResult {
matched: boolean;
reason: string | null;
}
/** Checks a post's caption + extracted hashtags against a set of blacklist entries. */
export function checkAgainstBlacklist(
caption: string | null,
hashtags: string[],
entries: BlacklistEntryLike[]
): BlacklistCheckResult {
const words = entries.filter((e) => e.type === "WORD").map((e) => e.value);
const links = entries.filter((e) => e.type === "LINK").map((e) => e.value);
const tags = entries.filter((e) => e.type === "HASHTAG").map((e) => e.value);
if (caption) {
const wordMatch = matchesWordBlacklist(caption, words);
if (wordMatch) return { matched: true, reason: `Blacklisted word: "${wordMatch}"` };
const linkMatch = matchesLinkBlacklist(caption, links);
if (linkMatch) return { matched: true, reason: `Blacklisted link: "${linkMatch}"` };
}
const tagMatch = matchesHashtagBlacklist(hashtags, tags);
if (tagMatch) return { matched: true, reason: `Blacklisted hashtag: #${tagMatch}` };
return { matched: false, reason: null };
}
+87 -1
View File
@@ -2,7 +2,7 @@ import "server-only";
import { z } from "zod";
import { randomUUID } from "crypto";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type PrismaClient, type ReportReason } from "@prisma/client";
import { AdminRoleType, BlacklistEntryType, type PrismaClient, type ReportReason } from "@prisma/client";
import { clerkClient } from "@clerk/nextjs/server";
import { router, protectedProcedure } from "@/trpc/init";
import {
@@ -15,6 +15,7 @@ import { uploadExport } from "@/lib/data-export-storage";
import { sendMail } from "@/lib/mail";
import { deleteOwnerAccount } from "@/lib/delete-account";
import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet";
import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist";
const DATA_EXPORT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -342,6 +343,91 @@ export const adminRouter = router({
return { success: true };
}),
// ─── Blacklist (Gitea #10 Phase 3) ────────────────────────────────────────────
listBlacklist: protectedProcedure
.input(z.object({ type: z.nativeEnum(BlacklistEntryType).optional() }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.blacklistEntry.findMany({
where: input.type ? { type: input.type } : undefined,
orderBy: { createdAt: "desc" },
});
}),
createBlacklistEntry: protectedProcedure
.input(
z.object({
type: z.nativeEnum(BlacklistEntryType),
value: z.string().min(1).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const value = input.value.trim().toLowerCase().replace(/^#/, "");
const entry = await ctx.prisma.blacklistEntry.upsert({
where: { type_value: { type: input.type, value } },
create: { type: input.type, value, createdBy: ctx.userId },
update: {},
});
// Retroactive scan — hide existing non-hidden posts matching this
// single new entry. Capped so a runaway match set can't hang the
// request; PawFeed's current scale makes a synchronous scan fine.
const RETROACTIVE_SCAN_LIMIT = 5000;
let matchingPostIds: string[] = [];
if (input.type === "HASHTAG") {
const rows = await ctx.prisma.postHashtag.findMany({
where: { hashtag: { tag: value }, post: { hiddenAt: null } },
select: { postId: true },
take: RETROACTIVE_SCAN_LIMIT,
});
matchingPostIds = rows.map((r) => r.postId);
} else {
const candidates = await ctx.prisma.post.findMany({
where: { hiddenAt: null, caption: { not: null } },
select: { id: true, caption: true },
take: RETROACTIVE_SCAN_LIMIT,
});
const matchFn = input.type === "WORD" ? matchesWordBlacklist : matchesLinkBlacklist;
matchingPostIds = candidates
.filter((p) => matchFn(p.caption!, [value]) !== null)
.map((p) => p.id);
}
if (matchingPostIds.length > 0) {
const reason = `Auto-hidden: retroactive blacklist scan (${input.type.toLowerCase()} "${value}")`;
await ctx.prisma.$transaction([
ctx.prisma.post.updateMany({
where: { id: { in: matchingPostIds } },
data: { hiddenAt: new Date() },
}),
ctx.prisma.moderationLog.createMany({
data: matchingPostIds.map((postId) => ({
action: "HIDE_POST",
targetType: "POST",
targetId: postId,
reason,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
})),
}),
]);
}
return { entry, hiddenCount: matchingPostIds.length };
}),
deleteBlacklistEntry: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
await ctx.prisma.blacklistEntry.delete({ where: { id: input.id } });
return { success: true };
}),
listComments: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
+24
View File
@@ -12,6 +12,7 @@ import { fanOutPost } from "@/lib/feed-helpers";
import { randomUUID } from "crypto";
import type { PrismaClient } from "@prisma/client";
import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers";
import { checkAgainstBlacklist } from "@/lib/blacklist";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
@@ -142,6 +143,29 @@ export const postsRouter = router({
// Notify @mentioned pets in caption
createMentionNotifications(ctx.prisma, input.caption ?? null, input.petId, post.id).catch(() => {});
// Auto-hide on blacklist match (Gitea #10 Phase 3) — never blocks
// creation, just hides it immediately after, same as a manual
// moderator hide (reversible via admin.unhidePost).
const blacklist = await ctx.prisma.blacklistEntry.findMany();
const blacklistResult = checkAgainstBlacklist(input.caption ?? null, tags, blacklist);
if (blacklistResult.matched) {
await ctx.prisma.$transaction([
ctx.prisma.post.update({ where: { id: post.id }, data: { hiddenAt: new Date() } }),
ctx.prisma.moderationLog.create({
data: {
action: "HIDE_POST",
targetType: "POST",
targetId: post.id,
reason: `Auto-hidden: ${blacklistResult.reason}`,
moderatorId: "SYSTEM",
moderatorRole: null,
ipAddress: null,
},
}),
]);
return { ...post, hiddenAt: new Date() };
}
return post;
}),