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:
1 parent
eb26cb5fbe
commit
7395d83194
7 files changed
+440
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user