diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 98dbed1..ed52b43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 29a6bf5..2f06490 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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(), diff --git a/src/app/p/[secret]/blacklist/page.tsx b/src/app/p/[secret]/blacklist/page.tsx new file mode 100644 index 0000000..e76e74b --- /dev/null +++ b/src/app/p/[secret]/blacklist/page.tsx @@ -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 = { + WORD: "Word", + LINK: "Link", + HASHTAG: "Hashtag", +}; + +const TYPE_BADGE_CLASS: Record = { + 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("all"); + const [newType, setNewType] = useState(BlacklistEntryType.WORD); + const [newValue, setNewValue] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(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 ( +
+
+

Blacklist

+

+ 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. +

+
+ + {/* Add entry */} +
+ + 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" + /> + +
+ + {/* Filter */} + + + {/* List */} + {isLoading ? ( +
+ +
+ ) : entries.length === 0 ? ( +
+
+ ) : ( +
+ {entries.map((entry) => ( +
+ + {TYPE_LABEL[entry.type]} + + + {entry.type === "HASHTAG" ? `#${entry.value}` : entry.value} + + + {new Date(entry.createdAt).toLocaleDateString()} + + +
+ ))} +
+ )} + + { if (!o) setDeleteTarget(null); }}> + + + Remove blacklist entry + +

+ Already-hidden posts stay hidden — this only stops future matches. +

+ + + + +
+
+
+ ); +} diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index 6482169..e4b3d1a 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -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 }, ] : []), ]; diff --git a/src/lib/blacklist.ts b/src/lib/blacklist.ts new file mode 100644 index 0000000..07edcc1 --- /dev/null +++ b/src/lib/blacklist.ts @@ -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 }; +} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index ed86aaf..cee4566 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -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 }) => { diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index d98732b..ccd8c19 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -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; }),